Compare commits

..

No commits in common. "master" and "v1.0.4" have entirely different histories.

46 changed files with 5951 additions and 13469 deletions

View File

@ -1,97 +0,0 @@
# Changelog
## 2025-03-15 - 2.3.0 - feat(platformservice)
Add AIBridge module and refactor service file paths for improved module organization
- Added new AIBridge class in ts/aibridge/classes.aibridge.ts.
- Renamed letter service file from ts/letter/letterservice.ts to ts/letter/classes.letterservice.ts and updated its index.
- Updated platformservice.ts to import letter and SMS services from new paths.
- Renamed SMS service file from ts/sms/smsservice.ts to ts/sms/classes.smsservice.ts and updated its index accordingly.
## 2025-03-15 - 2.2.1 - fix(platformservice)
Refactor module structure to update import paths and file organization
- Removed obsolete file 'ts/classes.platformservice.ts' and updated references to use 'ts/platformservice.ts'.
- Updated import paths in PlatformServiceDb, EmailService, and other modules to use new file structure.
- Renamed and moved files in the email, mta, letter, and sms directories to align with new module layout.
- Fixed references to external modules (e.g. '@serve.zone/interfaces', '@push.rocks/*', etc.) to reflect the updated paths.
## 2025-03-15 - 2.2.0 - feat(plugins)
Add smartproxy support by including the @push.rocks/smartproxy dependency and exporting it in the plugins module.
- Added '@push.rocks/smartproxy' dependency version '^4.1.0' to package.json
- Updated ts/plugins.ts to export the smartproxy module alongside other push.rocks modules
## 2025-03-15 - 2.1.0 - feat(MTA)
Update readme with detailed Mail Transfer Agent usage and examples
- Added a comprehensive MTA section with usage examples including SMTP server setup, DKIM signing/verification, SPF/DMARC support, and API integration
- Expanded the conclusion to highlight MTA capabilities alongside email, SMS, letter, and AI services
## 2025-03-15 - 2.0.0 - BREAKING CHANGE(platformservice)
Remove deprecated AIBridge module and update email service to use the MTA connector; update dependency versions and adjust build scripts in package.json.
- Completely remove the aibridge module files (aibridge.classes.aibridge.ts, aibridge.classes.aibridgedb.ts, aibridge.classes.openaibridge.ts, aibridge.paths.ts, aibridge.plugins.ts, and index.ts) as they are no longer needed.
- Switch the email service from using MailgunConnector to the new MTA connector for sending emails.
- Update dependency versions for @serve.zone/interfaces, @tsclass/tsclass, letterxpress, and uuid in package.json.
- Enhance the build script in package.json and add pnpm configuration.
## 2025-03-15 - 1.1.2 - fix(mta)
Expose HttpResponse.statusCode and add explicit generic type annotations in DNSManager cache retrieval
- Changed HttpResponse.statusCode from private to public to allow external access and inspection
- Added explicit generic type parameters in getFromCache calls for lookupMx and lookupTxt to enhance type safety
## 2025-03-15 - 1.1.1 - fix(paths)
Update directory paths to use a dedicated 'data' directory and add ensureDirectories function for proper directory creation.
- Refactored ts/paths.ts to define a base data directory using process.cwd().
- Reorganized MTA directories (keys, dns, emails sent/received/failed, logs) under the data directory.
- Added ensureDirectories function to create missing directories at runtime.
## 2025-03-15 - 1.1.1 - fix(mta)
Refactor API Manager and DKIMCreator: remove Express dependency in favor of Node's native HTTP server, add an HttpResponse helper to improve request handling, update path and authentication logic, and expose previously private DKIMCreator methods for API access.
- Replaced Express-based middleware with native HTTP server handling, including request body parsing and CORS headers.
- Introduced an HttpResponse helper class to standardize response writing.
- Updated route matching, parameter extraction, and error handling within the API Manager.
- Modified DKIMCreator methods (createDKIMKeys, storeDKIMKeys, createAndStoreDKIMKeys, and getDNSRecordForDomain) from private to public for better API accessibility.
- Updated plugin imports to include the native HTTP module.
## 2025-03-15 - 1.1.0 - feat(mta)
Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
- Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering
- Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods
- Update Email class to support multiple recipients, CC, BCC with input sanitization and validation
- Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob
- Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics
- Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration
## 2024-05-11 - 1.0.10 to 1.0.8 - core
Applied core fixes across several versions on this day.
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
## 2024-04-01 - 1.0.7 - core
Applied a core fix.
- Fixed core functionality for version 1.0.7
## 2024-03-19 - 1.0.6 - core
Applied a core fix.
- Fixed core functionality for version 1.0.6
## 2024-02-16 - 1.0.5 to 1.0.2 - core
Applied multiple core fixes in a contiguous range of versions.
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
## 2024-02-15 - 1.0.1 - core
Applied a core fix.
- Fixed core functionality for version 1.0.1
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain “1.0.x” commits) have been omitted from individual entries and are implicitly included in the version ranges above.

View File

@ -5,32 +5,10 @@
"githost": "gitlab.com",
"gitscope": "serve.zone",
"gitrepo": "platformservice",
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"npmPackagename": "@serve.zone/platformservice",
"license": "MIT",
"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"
]
"projectDomain": "serve.zone"
}
},
"npmci": {

View File

@ -1,8 +1,7 @@
{
"name": "@serve.zone/platformservice",
"private": true,
"version": "2.3.0",
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
"version": "1.0.4",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
@ -10,24 +9,22 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"start": "(node --max_old_space_size=250 ./cli.js)",
"start": "(node --max_old_space_size=100 ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany)",
"localPublish": ""
"build": "(tsbuild --web --allowimplicitany)"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.17",
"@git.zone/tsrun": "^1.2.8",
"@git.zone/tstest": "^1.0.88",
"@git.zone/tstest": "^1.0.28",
"@git.zone/tswatch": "^2.0.1",
"@push.rocks/tapbundle": "^5.0.22"
"@push.rocks/tapbundle": "^5.0.3"
},
"dependencies": {
"@api.global/typedrequest": "^3.0.19",
"@api.global/typedserver": "^3.0.27",
"@api.global/typedrequest": "^3.0.4",
"@api.global/typedserver": "^3.0.20",
"@api.global/typedsocket": "^3.0.0",
"@apiclient.xyz/cloudflare": "^6.0.3",
"@apiclient.xyz/letterxpress": "^1.0.20",
"@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.0.5",
"@push.rocks/smartdata": "^5.0.7",
@ -36,45 +33,13 @@
"@push.rocks/smartmail": "^1.0.24",
"@push.rocks/smartpath": "^5.0.5",
"@push.rocks/smartpromise": "^4.0.3",
"@push.rocks/smartproxy": "^4.1.0",
"@push.rocks/smartrequest": "^2.0.21",
"@push.rocks/smartrule": "^2.0.1",
"@push.rocks/smartrx": "^3.0.7",
"@push.rocks/smartstate": "^2.0.0",
"@serve.zone/interfaces": "^4.12.1",
"@tsclass/tsclass": "^5.0.0",
"@types/mailparser": "^3.4.5",
"@serve.zone/interfaces": "^1.0.34",
"@tsclass/tsclass": "^4.0.51",
"mailauth": "^4.6.5",
"mailparser": "^3.6.9",
"uuid": "^11.1.0"
},
"keywords": [
"mail service",
"SMS",
"letter delivery",
"AI services",
"SMTP server",
"mail parsing",
"DKIM",
"platform service",
"mailgun integration",
"letterXpress",
"OpenAI",
"Anthropic AI",
"DKIM signing",
"mail forwarding",
"SMTP TLS",
"domain management",
"email templating",
"rule management",
"SMTP STARTTLS",
"DNS management"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
"mailparser": "^3.6.7",
"uuid": "^9.0.1"
}
}

13704
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

215
readme.md
View File

@ -1,200 +1,31 @@
# @serve.zone/platformservice
contains the platformservice container with mail, sms, letter, ai services.
## Install
## 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/)
To install `@serve.zone/platformservice`, run the following command:
## Status for master
```sh
npm install @serve.zone/platformservice --save
```
Make sure you have Node.js and npm installed on your system to use this package.
Status Category | Status Badge
-- | --
GitLab Pipelines | [![pipeline status](https://gitlab.com/serve.zone/platformservice/badges/master/pipeline.svg)](https://lossless.cloud)
GitLab Pipline Test Coverage | [![coverage report](https://gitlab.com/serve.zone/platformservice/badges/master/coverage.svg)](https://lossless.cloud)
npm | [![npm downloads per month](https://badgen.net/npm/dy/@serve.zone/platformservice)](https://lossless.cloud)
Snyk | [![Known Vulnerabilities](https://badgen.net/snyk/serve.zone/platformservice)](https://lossless.cloud)
TypeScript Support | [![TypeScript](https://badgen.net/badge/TypeScript/>=%203.x/blue?icon=typescript)](https://lossless.cloud)
node Support | [![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
Code Style | [![Code Style](https://badgen.net/badge/style/prettier/purple)](https://lossless.cloud)
PackagePhobia (total standalone install weight) | [![PackagePhobia](https://badgen.net/packagephobia/install/@serve.zone/platformservice)](https://lossless.cloud)
PackagePhobia (package size on registry) | [![PackagePhobia](https://badgen.net/packagephobia/publish/@serve.zone/platformservice)](https://lossless.cloud)
BundlePhobia (total size when bundled) | [![BundlePhobia](https://badgen.net/bundlephobia/minzip/@serve.zone/platformservice)](https://lossless.cloud)
## Usage
Use TypeScript for best in class intellisense.
For further information read the linked docs at the top of this readme.
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();
```
### Mail Transfer Agent (MTA)
The platform includes a robust Mail Transfer Agent (MTA) for enterprise-grade email handling with complete control over the email delivery process:
```mermaid
graph TD
API[API Clients] --> ApiManager
SMTP[External SMTP Servers] <--> SMTPServer
subgraph "MTA Service"
MtaService[MTA Service] --> SMTPServer[SMTP Server]
MtaService --> EmailSendJob[Email Send Job]
MtaService --> DnsManager[DNS Manager]
MtaService --> DkimCreator[DKIM Creator]
ApiManager[API Manager] --> MtaService
end
subgraph "External Services"
DnsManager <--> DNS[DNS Servers]
EmailSendJob <--> MXServers[MX Servers]
end
```
The MTA service provides:
- Complete SMTP server for receiving emails
- DKIM signing and verification
- SPF and DMARC support
- DNS record management
- Retry logic with queue processing
- TLS encryption
Here's how to use the MTA service:
```ts
import { MtaService, Email } from '@serve.zone/platformservice';
async function useMtaService() {
// Initialize MTA service
const mtaService = new MtaService(platformService);
await mtaService.start();
// Send an email
const email = new Email({
from: 'sender@yourdomain.com',
to: 'recipient@example.com',
subject: 'Hello World',
text: 'This is a test email',
html: '<p>This is a <b>test</b> email</p>',
attachments: [] // Optional attachments
});
const emailId = await mtaService.send(email);
console.log(`Email queued with ID: ${emailId}`);
// Check email status
const status = mtaService.getEmailStatus(emailId);
console.log(`Email status: ${status.status}`);
// Set up API for external access
const apiManager = new ApiManager(mtaService);
await apiManager.start(3000);
console.log('MTA API running on port 3000');
}
useMtaService();
```
The MTA provides key advantages for applications requiring:
- High-volume email sending
- Compliance with email authentication standards
- Detailed delivery tracking
- Custom email handling logic
- Multi-domain email management
- Complete control over email infrastructure
### Leveraging AI Services
The platform also integrates AI functionalities, allowing for innovative use cases like generating content, analyzing text, or automating responses:
```ts
import { AiService } from '@serve.zone/platformservice';
async function useAiService() {
const aiService = new AiService('OPENAI_API_KEY'); // Replace with your real API key
const response = await aiService.generateText('Prompt for the AI service.');
console.log(`AI response: ${response}`);
}
useAiService();
```
### Conclusion
The `@serve.zone/platformservice` offers a robust set of features for modern application requirements, including but not limited to communication and AI services. By following the examples above, developers can integrate these services into their applications, harnessing the power of email, SMS, letters, MTA capabilities, and artificial intelligence seamlessly.
## Legal
> MIT licensed | **&copy;** [Task Venture Capital GmbH](https://task.vc)
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)

View File

@ -1,5 +0,0 @@
import { tap, expect } from '@push.rocks/tapbundle';
tap.test('should create a platform service', async () => {});
tap.start();

View File

@ -1,8 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
* autocreated commitinfo by @pushrocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/platformservice',
version: '2.3.0',
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
version: '1.0.4',
description: 'contains the platformservice container with mail, sms, letter, ai services.'
}

View File

@ -1,3 +0,0 @@
export class AIBridge {
}

View File

@ -0,0 +1,21 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { PlatformServiceDb } from './classes.platformservicedb.js'
export class SzPlatformService {
public projectinfo: plugins.projectinfo.ProjectInfo;
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
public platformserviceDb: PlatformServiceDb;
public typedserver: plugins.typedserver.TypedServer;
public typedrouter = new plugins.typedrequest.TypedRouter();
public async start() {
this.platformserviceDb = new PlatformServiceDb(this);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
this.typedserver = new plugins.typedserver.TypedServer({
cors: true,
});
await this.typedserver.start();
}
}

View File

@ -1,5 +1,5 @@
import * as plugins from './plugins.js';
import { SzPlatformService } from './platformservice.js';
import { SzPlatformService } from './classes.platformservice.js';

View File

@ -1,87 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
export class ApiManager {
public emailRef: EmailService;
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
// Register API endpoints
this.registerApiEndpoints();
}
/**
* Register API endpoints for email functionality
*/
private registerApiEndpoints() {
// Register the SendEmail endpoint
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IRequest_SendEmail>(
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
const mailToSend = new plugins.smartmail.Smartmail({
body: requestData.body,
from: requestData.from,
subject: requestData.title,
});
if (requestData.attachments) {
for (const attachment of requestData.attachments) {
mailToSend.addAttachment(
await plugins.smartfile.SmartFile.fromString(
attachment.name,
attachment.binaryAttachmentString,
'binary'
)
);
}
}
// Send email through the service which will route to the appropriate connector
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
logger.log(
'info',
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
{
eventType: 'sentEmail',
email: {
to: requestData.to,
subject: mailToSend.getSubject(),
},
}
);
return {
responseId: emailId,
};
})
);
// Add endpoint to check email status
this.typedRouter.addTypedHandler<{ emailId: string }>(
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
// If MTA is enabled, use it to check status
if (this.emailRef.mtaConnector) {
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
return status;
}
// For Mailgun, we don't have a status check implementation currently
return {
status: 'unknown',
details: { message: 'Status tracking not available for current provider' }
};
})
);
// Add statistics endpoint
this.typedRouter.addTypedHandler<void>(
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
return this.emailRef.getStats();
})
);
}
}

View File

@ -1,169 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
// Import MTA classes
import {
MtaService,
Email as MtaEmail,
type IEmailOptions,
DeliveryStatus,
type IAttachment
} from '../mta/index.js';
export class MtaConnector {
public emailRef: EmailService;
private mtaService: MtaService;
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
this.emailRef = emailRefArg;
this.mtaService = mtaService || this.emailRef.mtaService;
}
/**
* Send an email using the MTA service
* @param smartmail The email to send
* @param toAddresses Recipients (comma-separated or array)
* @param options Additional options
*/
public async sendEmail(
smartmail: plugins.smartmail.Smartmail<>,
toAddresses: string | string[],
options: any = {}
): Promise<string> {
try {
// Process recipients
const toArray = Array.isArray(toAddresses)
? toAddresses
: toAddresses.split(',').map(addr => addr.trim());
// Map SmartMail attachments to MTA attachments
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
return {
filename: attachment.parsedPath.base,
content: Buffer.from(attachment.contentBuffer),
contentType: (attachment as any)?.getContentType?.() || 'application/octet-stream' // TODO: revisit after smartfile has been updated
};
});
// Create MTA Email
const mtaEmail = new MtaEmail({
from: smartmail.options.from,
to: toArray,
subject: smartmail.getSubject(),
text: smartmail.getBody(false), // Plain text version
html: smartmail.getBody(true), // HTML version
attachments
});
// Send using MTA
const emailId = await this.mtaService.send(mtaEmail);
logger.log('info', `Email sent via MTA to ${toAddresses}`, {
eventType: 'sentEmail',
provider: 'mta',
emailId,
to: toAddresses
});
return emailId;
} catch (error) {
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
eventType: 'emailError',
provider: 'mta',
error: error.message
});
throw error;
}
}
/**
* Retrieve and process an incoming email
* For MTA, this would handle an email already received by the SMTP server
* @param emailData The raw email data or identifier
*/
public async receiveEmail(emailData: string): Promise<plugins.smartmail.Smartmail<>> {
try {
// In a real implementation, this would retrieve an email from the MTA storage
// For now, we can use a simplified approach:
// Parse the email (assuming emailData is a raw email or a file path)
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
// Create a Smartmail from the parsed email
const smartmail = new plugins.smartmail.Smartmail({
from: parsedEmail.from?.text || '',
subject: parsedEmail.subject || '',
body: parsedEmail.html || parsedEmail.text || '',
creationObjectRef: {
From: parsedEmail.from?.text || '',
To: parsedEmail.to?.text || '',
Subject: parsedEmail.subject || ''
}
});
// Add attachments if present
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
for (const attachment of parsedEmail.attachments) {
smartmail.addAttachment(
await plugins.smartfile.SmartFile.fromBuffer(
attachment.filename || 'attachment',
attachment.content
)
);
}
}
return smartmail;
} catch (error) {
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
eventType: 'emailError',
provider: 'mta',
error: error.message
});
throw error;
}
}
/**
* Check the status of a sent email
* @param emailId The email ID to check
*/
public async checkEmailStatus(emailId: string): Promise<{
status: string;
details?: any;
}> {
try {
const status = this.mtaService.getEmailStatus(emailId);
if (!status) {
return {
status: 'unknown',
details: { message: 'Email not found' }
};
}
return {
status: status.status,
details: {
attempts: status.attempts,
lastAttempt: status.lastAttempt,
nextAttempt: status.nextAttempt,
error: status.error?.message
}
};
} catch (error) {
logger.log('error', `Failed to check email status: ${error.message}`, {
eventType: 'emailError',
provider: 'mta',
emailId,
error: error.message
});
return {
status: 'error',
details: { message: error.message }
};
}
}
}

View File

@ -1,131 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { MtaConnector } from './classes.connector.mta.js';
import { RuleManager } from './classes.rulemanager.js';
import { ApiManager } from './classes.apimanager.js';
import { logger } from '../logger.js';
import type { SzPlatformService } from '../platformservice.js';
// Import MTA service
import { MtaService, type IMtaConfig } from '../mta/index.js';
export interface IEmailConstructorOptions {
useMta?: boolean;
mtaConfig?: IMtaConfig;
}
/**
* Email service with support for both Mailgun and local MTA
*/
export class EmailService {
public platformServiceRef: SzPlatformService;
// typedrouter
public typedrouter = new plugins.typedrequest.TypedRouter();
// connectors
public mtaConnector: MtaConnector;
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
// MTA service
public mtaService: MtaService;
// services
public apiManager: ApiManager;
public ruleManager: RuleManager;
// configuration
private config: IEmailConstructorOptions;
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
this.platformServiceRef = platformServiceRefArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
// Set default options
this.config = {
useMta: options.useMta ?? true,
mtaConfig: options.mtaConfig || {}
};
if (this.config.useMta) {
// Initialize MTA service
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
// Initialize MTA connector
this.mtaConnector = new MtaConnector(this);
}
// Initialize API manager and rule manager
this.apiManager = new ApiManager(this);
this.ruleManager = new RuleManager(this);
// Set up MTA SMTP server webhook if using MTA
if (this.config.useMta) {
// The MTA SMTP server will handle incoming emails directly
// through its SMTP protocol. No additional webhook needed.
}
}
/**
* Start the email service
*/
public async start() {
// Initialize rule manager
await this.ruleManager.init();
// Start MTA service if enabled
if (this.config.useMta && this.mtaService) {
await this.mtaService.start();
logger.log('success', 'Started MTA service');
}
logger.log('success', `Started email service`);
}
/**
* Stop the email service
*/
public async stop() {
// Stop MTA service if it's running
if (this.config.useMta && this.mtaService) {
await this.mtaService.stop();
logger.log('info', 'Stopped MTA service');
}
logger.log('info', 'Stopped email service');
}
/**
* Send an email using the configured provider (Mailgun or MTA)
* @param email The email to send
* @param to Recipient(s)
* @param options Additional options
*/
public async sendEmail(
email: plugins.smartmail.Smartmail<>,
to: string | string[],
options: any = {}
): Promise<string> {
// Determine which connector to use
if (this.config.useMta && this.mtaConnector) {
return this.mtaConnector.sendEmail(email, to, options);
} else {
throw new Error('No email provider configured');
}
}
/**
* Get email service statistics
*/
public getStats() {
const stats: any = {
activeProviders: []
};
if (this.config.useMta) {
stats.activeProviders.push('mta');
stats.mta = this.mtaService.getStats();
}
return stats;
}
}

View File

@ -1,177 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
export class RuleManager {
public emailRef: EmailService;
public smartruleInstance = new plugins.smartrule.SmartRule<
plugins.smartmail.Smartmail<any>
>();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
// Register MTA handler for incoming emails if MTA is enabled
if (this.emailRef.mtaService) {
this.setupMtaIncomingHandler();
}
}
/**
* Set up handler for incoming emails via MTA's SMTP server
*/
private setupMtaIncomingHandler() {
// The original MtaService doesn't have a direct callback for incoming emails,
// but we can modify this approach based on how you prefer to integrate.
// One option would be to extend the MtaService to add an event emitter.
// For now, we'll use a directory watcher as an example
// This would watch the directory where MTA saves incoming emails
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
// Simple file watcher (in real implementation, use proper file watching)
// This is just conceptual - would need modification to work with your specific setup
this.watchIncomingEmails(incomingDir);
}
/**
* Watch directory for incoming emails (conceptual implementation)
*/
private watchIncomingEmails(directory: string) {
console.log(`Watching for incoming emails in: ${directory}`);
// Conceptual - in a real implementation, set up proper file watching
// or modify the MTA to emit events when emails are received
/*
// Example using a file watcher:
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
if (eventType === 'rename' && filename.endsWith('.eml')) {
const filePath = plugins.path.join(directory, filename);
await this.handleMtaIncomingEmail(filePath);
}
});
*/
}
/**
* Handle incoming email received via MTA
*/
public async handleMtaIncomingEmail(emailPath: string) {
try {
// Process the email file
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
console.log('=======================');
console.log('Received a mail via MTA:');
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
logger.log(
'info',
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
{
eventType: 'receivedEmail',
provider: 'mta',
email: {
from: fetchedSmartmail.options.creationObjectRef.From,
to: fetchedSmartmail.options.creationObjectRef.To,
subject: fetchedSmartmail.options.creationObjectRef.Subject,
},
}
);
// Process with rules
this.smartruleInstance.makeDecision(fetchedSmartmail);
} catch (error) {
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
eventType: 'emailError',
provider: 'mta',
error: error.message
});
}
}
public async init() {
// Setup email rules
await this.createForwards();
}
/**
* creates the default forwards
*/
public async createForwards() {
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
console.log(`${forwards.length} forward rules configured:`);
for (const forward of forwards) {
console.log(forward);
}
for (const forward of forwards) {
this.smartruleInstance.createRule(
10,
async (smartmailArg) => {
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
}, false);
if (matched) {
console.log('Forward rule matched');
console.log(forward);
return 'apply-continue';
} else {
return 'continue';
}
},
async (smartmailArg: plugins.smartmail.Smartmail<any>) => {
forward.forwardedToAddress.map(async (toArg) => {
const forwardedSmartMail = new plugins.smartmail.Smartmail({
body:
`
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
<div><b>Original Sender:</b></div>
<div>${smartmailArg.options.creationObjectRef.From}</div>
<div><b>Original Recipient:</b></div>
<div>${smartmailArg.options.creationObjectRef.To}</div>
<div><b>Forwarded to:</b></div>
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
return `${pVal ? pVal + ', ' : ''}${cVal}`;
}, null)}</div>
<div><b>Subject:</b></div>
<div>${smartmailArg.getSubject()}</div>
<div><b>The original body can be found below.</b></div>
</div>
` + smartmailArg.getBody(),
from: 'forwarder@mail.lossless.one',
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
});
for (const attachment of smartmailArg.attachments) {
forwardedSmartMail.addAttachment(attachment);
}
// Use the EmailService's sendEmail method to send with the appropriate provider
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
console.log(`forwarded mail to ${toArg}`);
logger.log(
'info',
`email from ${
smartmailArg.options.creationObjectRef.From
} to ${toArg} with subject '${smartmailArg.getSubject()}'`,
{
eventType: 'forwardedEmail',
email: {
from: smartmailArg.options.creationObjectRef.From,
to: smartmailArg.options.creationObjectRef.To,
forwardedTo: toArg,
subject: smartmailArg.options.creationObjectRef.Subject,
},
}
);
});
}
);
}
}
}

View File

@ -0,0 +1,51 @@
import * as plugins from '../plugins.js';
import { EmailService } from './email.classes.emailservice.js';
import { logger } from '../logger.js';
export class ApiManager {
public emailRef: EmailService;
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IRequest_SendEmail>(
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
const mailToSend = new plugins.smartmail.Smartmail({
body: requestData.body,
from: requestData.from,
subject: requestData.title,
});
if (requestData.attachments) {
for (const attachment of requestData.attachments) {
mailToSend.addAttachment(
await plugins.smartfile.SmartFile.fromString(
attachment.name,
attachment.binaryAttachmentString,
'binary'
)
);
}
}
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
logger.log(
'info',
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
{
eventType: 'sentEmail',
email: {
to: requestData.to,
subject: mailToSend.getSubject(),
},
}
);
return {
responseId: 'abc', // TODO: generate proper response id
};
})
);
}
}

View File

@ -0,0 +1,30 @@
import * as plugins from './email.plugins.js';
import { EmailService } from './email.classes.emailservice.js';
export class MailgunConnector {
public emailRef: EmailService;
public mailgunAccount: plugins.mailgun.MailgunAccount;
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
region: 'eu',
});
this.mailgunAccount.addSmtpCredentials(
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
);
}
public async sendEmail(
smartMailArg: plugins.smartmail.Smartmail<any>,
toArg: string,
dataArg: any = {}
) {
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
}
public async receiveEmail(messageUrl: string) {
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
}
}

View File

@ -0,0 +1,48 @@
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 '../logger.js';
import type { SzPlatformService } from '../classes.platformservice.js';
export class EmailService {
public platformServiceRef: SzPlatformService;
// typedrouter
public typedrouter = new plugins.typedrequest.TypedRouter();
// connectors
public mailgunConnector: MailgunConnector;
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
// server
public apiManager = new ApiManager(this);
public ruleManager: RuleManager;
constructor(platformServiceRefArg: SzPlatformService) {
this.platformServiceRef = platformServiceRefArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
this.mailgunConnector = new MailgunConnector(this);
this.ruleManager = new RuleManager(this);
this.platformServiceRef.typedserver.server.addRoute(
'/mailgun-notify',
new plugins.typedserver.servertools.Handler('POST', async (req, res) => {
console.log('Got a mailgun email notification');
res.status(200);
res.end();
this.ruleManager.handleNotification(req.body);
})
);
}
public async start() {
await this.ruleManager.init();
logger.log('success', `Started email service`);
}
public async stop() {
}
}

View File

@ -0,0 +1,137 @@
import * as plugins from './email.plugins.js';
import { EmailService } from './email.classes.emailservice.js';
import { logger } from './email.logging.js';
export class RuleManager {
public emailRef: EmailService;
public smartruleInstance = new plugins.smartrule.SmartRule<
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
>();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
}
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
console.log(notification['message-url']);
// basic checks here
// none for now
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
notification['message-url']
);
console.log('=======================');
console.log('Received a mail:');
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
logger.log(
'info',
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
{
eventType: 'receivedEmail',
email: {
from: fetchedSmartmail.options.creationObjectRef.From,
to: fetchedSmartmail.options.creationObjectRef.To,
subject: fetchedSmartmail.options.creationObjectRef.Subject,
},
}
);
this.smartruleInstance.makeDecision(fetchedSmartmail);
}
public async init() {
// lets forward stuff
await this.createForwards();
}
/**
* creates the default forwards
*/
public async createForwards() {
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
{
originalToAddress: ['bot@mail.nevermind.group'],
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
},
{
originalToAddress: ['legal@mail.lossless.com'],
forwardedToAddress: ['phil@lossless.com'],
},
{
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
forwardedToAddress: ['phil@lossless.com'],
},
];
console.log(`${forwards.length} forward rules configured:`);
for (const forward of forwards) {
console.log(forward);
}
for (const forward of forwards) {
this.smartruleInstance.createRule(
10,
async (smartmailArg) => {
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
}, false);
if (matched) {
console.log('Forward rule matched');
console.log(forward);
return 'apply-continue';
} else {
return 'continue';
}
},
async (smartmailArg: plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>) => {
forward.forwardedToAddress.map(async (toArg) => {
const forwardedSmartMail = new plugins.smartmail.Smartmail({
body:
`
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
<div><b>Original Sender:</b></div>
<div>${smartmailArg.options.creationObjectRef.From}</div>
<div><b>Original Recipient:</b></div>
<div>${smartmailArg.options.creationObjectRef.To}</div>
<div><b>Forwarded to:</b></div>
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
return `${pVal ? pVal + ', ' : ''}${cVal}`;
}, null)}</div>
<div><b>Subject:</b></div>
<div>${smartmailArg.getSubject()}</div>
<div><b>The original body can be found below.</b></div>
</div>
` + smartmailArg.getBody(),
from: 'forwarder@mail.lossless.one',
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
});
for (const attachment of smartmailArg.attachments) {
forwardedSmartMail.addAttachment(attachment);
}
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
console.log(`forwarded mail to ${toArg}`);
logger.log(
'info',
`email from ${
smartmailArg.options.creationObjectRef.From
} to phil@lossless.com with subject '${smartmailArg.getSubject()}'`,
{
eventType: 'forwardedEmail',
email: {
from: smartmailArg.options.creationObjectRef.From,
to: smartmailArg.options.creationObjectRef.To,
forwardedTo: toArg,
subject: smartmailArg.options.creationObjectRef.Subject,
},
}
);
});
}
);
}
}
}

View File

@ -1,4 +1,4 @@
import * as plugins from '../plugins.js';
import * as plugins from './email.plugins.js';
export class TemplateManager {
public smartmailDefault = new plugins.smartmail.Smartmail({

View File

@ -1,4 +1,4 @@
export * from './00_commitinfo_data.js';
import { SzPlatformService } from './platformservice.js';
import { SzPlatformService } from './classes.platformservice.js';
export const runCli = async () => {}

View File

@ -1,41 +0,0 @@
import type { SzPlatformService } from '../platformservice.js';
import * as plugins from '../plugins.js';
export interface ILetterConstructorOptions {
letterxpressUser: string;
letterxpressToken: string;
}
export class LetterService {
public platformServiceRef: SzPlatformService;
public options: ILetterConstructorOptions;
public letterxpressAccount: plugins.letterxpress.LetterXpressAccount;
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ILetterConstructorOptions) {
this.platformServiceRef = platformServiceRefArg;
this.options = optionsArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler<
plugins.servezoneInterfaces.platformservice.letter.IRequest_SendLetter
>(new plugins.typedrequest.TypedHandler('sendLetter', async dataArg => {
if(dataArg.needsCover) {
}
return {
processId: '',
}
}));
}
public async start() {
this.letterxpressAccount = new plugins.letterxpress.LetterXpressAccount({
username: this.options.letterxpressUser,
apiKey: this.options.letterxpressToken,
});
await this.letterxpressAccount.start();
}
public async stop() {}
}

View File

@ -1 +0,0 @@
export * from './classes.letterservice.js';

View File

@ -1,956 +0,0 @@
import * as plugins from '../plugins.js';
import { Email } from './classes.email.js';
import type { IEmailOptions } from './classes.email.js';
import { DeliveryStatus } from './classes.emailsendjob.js';
import type { MtaService } from './classes.mta.js';
import type { IDnsRecord } from './classes.dnsmanager.js';
/**
* Authentication options for API requests
*/
interface AuthOptions {
/** Required API keys for different endpoints */
apiKeys: Map<string, string[]>;
/** JWT secret for token-based authentication */
jwtSecret?: string;
/** Whether to validate IP addresses */
validateIp?: boolean;
/** Allowed IP addresses */
allowedIps?: string[];
}
/**
* Rate limiting options for API endpoints
*/
interface RateLimitOptions {
/** Maximum requests per window */
maxRequests: number;
/** Time window in milliseconds */
windowMs: number;
/** Whether to apply per endpoint */
perEndpoint?: boolean;
/** Whether to apply per IP */
perIp?: boolean;
}
/**
* API route definition
*/
interface ApiRoute {
/** HTTP method */
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
/** Path pattern */
path: string;
/** Handler function */
handler: (req: any, res: any) => Promise<any>;
/** Required authentication level */
authLevel: 'none' | 'basic' | 'admin';
/** Rate limiting options */
rateLimit?: RateLimitOptions;
/** Route description */
description?: string;
}
/**
* Email send request
*/
interface SendEmailRequest {
/** Email details */
email: IEmailOptions;
/** Whether to validate domains before sending */
validateDomains?: boolean;
/** Priority level (1-5, 1 = highest) */
priority?: number;
}
/**
* Email status response
*/
interface EmailStatusResponse {
/** Email ID */
id: string;
/** Current status */
status: DeliveryStatus;
/** Send time */
sentAt?: Date;
/** Delivery time */
deliveredAt?: Date;
/** Error message if failed */
error?: string;
/** Recipient address */
recipient: string;
/** Number of delivery attempts */
attempts: number;
/** Next retry time */
nextRetry?: Date;
}
/**
* Domain verification response
*/
interface DomainVerificationResponse {
/** Domain name */
domain: string;
/** Whether the domain is verified */
verified: boolean;
/** Verification details */
details: {
/** SPF record status */
spf: {
valid: boolean;
record?: string;
error?: string;
};
/** DKIM record status */
dkim: {
valid: boolean;
record?: string;
error?: string;
};
/** DMARC record status */
dmarc: {
valid: boolean;
record?: string;
error?: string;
};
/** MX record status */
mx: {
valid: boolean;
records?: string[];
error?: string;
};
};
}
/**
* API error response
*/
interface ApiError {
/** Error code */
code: string;
/** Error message */
message: string;
/** Detailed error information */
details?: any;
}
/**
* Simple HTTP Response helper
*/
class HttpResponse {
private headers: Record<string, string> = {
'Content-Type': 'application/json'
};
public statusCode: number = 200;
constructor(private res: any) {}
header(name: string, value: string): HttpResponse {
this.headers[name] = value;
return this;
}
status(code: number): HttpResponse {
this.statusCode = code;
return this;
}
json(data: any): void {
this.res.writeHead(this.statusCode, this.headers);
this.res.end(JSON.stringify(data));
}
end(): void {
this.res.writeHead(this.statusCode, this.headers);
this.res.end();
}
}
/**
* API Manager for MTA service
*/
export class ApiManager {
/** TypedRouter for API routing */
public typedrouter = new plugins.typedrequest.TypedRouter();
/** MTA service reference */
private mtaRef: MtaService;
/** HTTP server */
private server: any;
/** Authentication options */
private authOptions: AuthOptions;
/** API routes */
private routes: ApiRoute[] = [];
/** Rate limiters */
private rateLimiters: Map<string, {
count: number;
resetTime: number;
clients: Map<string, {
count: number;
resetTime: number;
}>;
}> = new Map();
/**
* Initialize API Manager
* @param mtaRef MTA service reference
*/
constructor(mtaRef?: MtaService) {
this.mtaRef = mtaRef;
// Default authentication options
this.authOptions = {
apiKeys: new Map(),
validateIp: false,
allowedIps: []
};
// Register routes
this.registerRoutes();
// Create HTTP server with request handler
this.server = plugins.http.createServer(this.handleRequest.bind(this));
}
/**
* Set MTA service reference
* @param mtaRef MTA service reference
*/
public setMtaService(mtaRef: MtaService): void {
this.mtaRef = mtaRef;
}
/**
* Configure authentication options
* @param options Authentication options
*/
public configureAuth(options: Partial<AuthOptions>): void {
this.authOptions = {
...this.authOptions,
...options
};
}
/**
* Handle HTTP request
*/
private async handleRequest(req: any, res: any): Promise<void> {
const start = Date.now();
// Create a response helper
const response = new HttpResponse(res);
// Add CORS headers
response.header('Access-Control-Allow-Origin', '*');
response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
// Handle preflight OPTIONS request
if (req.method === 'OPTIONS') {
return response.status(200).end();
}
try {
// Parse URL to get path and query
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const path = url.pathname;
// Collect request body if POST or PUT
let body = '';
if (req.method === 'POST' || req.method === 'PUT') {
await new Promise<void>((resolve, reject) => {
req.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
req.on('end', () => {
resolve();
});
req.on('error', (err: Error) => {
reject(err);
});
});
// Parse body as JSON if Content-Type is application/json
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/json')) {
try {
req.body = JSON.parse(body);
} catch (error) {
return response.status(400).json({
code: 'INVALID_JSON',
message: 'Invalid JSON in request body'
});
}
} else {
req.body = body;
}
}
// Add authentication level to request
req.authLevel = 'none';
// Check API key
const apiKey = req.headers['x-api-key'];
if (apiKey) {
for (const [level, keys] of this.authOptions.apiKeys.entries()) {
if (keys.includes(apiKey)) {
req.authLevel = level;
break;
}
}
}
// Check JWT token (if configured)
if (this.authOptions.jwtSecret && req.headers.authorization) {
try {
const token = req.headers.authorization.split(' ')[1];
// Note: We would need to add JWT verification
// Using a simple placeholder for now
const decoded = { level: 'none' }; // Simplified - would use actual JWT library
if (decoded && decoded.level) {
req.authLevel = decoded.level;
req.user = decoded;
}
} catch (error) {
// Invalid token, but don't fail the request yet
console.error('Invalid JWT token:', error.message);
}
}
// Check IP address (if configured)
if (this.authOptions.validateIp) {
const clientIp = req.socket.remoteAddress;
if (!this.authOptions.allowedIps.includes(clientIp)) {
return response.status(403).json({
code: 'FORBIDDEN',
message: 'IP address not allowed'
});
}
}
// Find matching route
const route = this.findRoute(req.method, path);
if (!route) {
return response.status(404).json({
code: 'NOT_FOUND',
message: 'Endpoint not found'
});
}
// Check authentication
if (route.authLevel !== 'none' && req.authLevel !== route.authLevel && req.authLevel !== 'admin') {
return response.status(403).json({
code: 'FORBIDDEN',
message: `This endpoint requires ${route.authLevel} access`
});
}
// Check rate limit
if (route.rateLimit) {
const exceeded = this.checkRateLimit(route, req);
if (exceeded) {
return response.status(429).json({
code: 'RATE_LIMIT_EXCEEDED',
message: 'Rate limit exceeded, please try again later'
});
}
}
// Extract path parameters
const pathParams = this.extractPathParams(route.path, path);
req.params = pathParams;
// Extract query parameters
req.query = {};
for (const [key, value] of url.searchParams.entries()) {
req.query[key] = value;
}
// Handle the request
await route.handler(req, response);
// Log request
const duration = Date.now() - start;
console.log(`[API] ${req.method} ${path} ${response.statusCode} ${duration}ms`);
} catch (error) {
console.error(`Error handling request:`, error);
// Send appropriate error response
const status = error.status || 500;
const apiError: ApiError = {
code: error.code || 'INTERNAL_ERROR',
message: error.message || 'Internal server error'
};
if (process.env.NODE_ENV !== 'production') {
apiError.details = error.stack;
}
response.status(status).json(apiError);
}
}
/**
* Find a route matching the method and path
*/
private findRoute(method: string, path: string): ApiRoute | null {
for (const route of this.routes) {
if (route.method === method && this.pathMatches(route.path, path)) {
return route;
}
}
return null;
}
/**
* Check if a path matches a route pattern
*/
private pathMatches(pattern: string, path: string): boolean {
// Convert route pattern to regex
const patternParts = pattern.split('/');
const pathParts = path.split('/');
if (patternParts.length !== pathParts.length) {
return false;
}
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
// Parameter - always matches
continue;
}
if (patternParts[i] !== pathParts[i]) {
return false;
}
}
return true;
}
/**
* Extract path parameters from URL
*/
private extractPathParams(pattern: string, path: string): Record<string, string> {
const params: Record<string, string> = {};
const patternParts = pattern.split('/');
const pathParts = path.split('/');
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
const paramName = patternParts[i].substring(1);
params[paramName] = pathParts[i];
}
}
return params;
}
/**
* Register API routes
*/
private registerRoutes(): void {
// Email routes
this.addRoute({
method: 'POST',
path: '/api/email/send',
handler: this.handleSendEmail.bind(this),
authLevel: 'basic',
description: 'Send an email'
});
this.addRoute({
method: 'GET',
path: '/api/email/status/:id',
handler: this.handleGetEmailStatus.bind(this),
authLevel: 'basic',
description: 'Get email delivery status'
});
// Domain routes
this.addRoute({
method: 'GET',
path: '/api/domain/verify/:domain',
handler: this.handleVerifyDomain.bind(this),
authLevel: 'basic',
description: 'Verify domain DNS records'
});
this.addRoute({
method: 'GET',
path: '/api/domain/records/:domain',
handler: this.handleGetDomainRecords.bind(this),
authLevel: 'basic',
description: 'Get recommended DNS records for domain'
});
// DKIM routes
this.addRoute({
method: 'POST',
path: '/api/dkim/generate/:domain',
handler: this.handleGenerateDkim.bind(this),
authLevel: 'admin',
description: 'Generate DKIM keys for domain'
});
this.addRoute({
method: 'GET',
path: '/api/dkim/public/:domain',
handler: this.handleGetDkimPublicKey.bind(this),
authLevel: 'basic',
description: 'Get DKIM public key for domain'
});
// Stats route
this.addRoute({
method: 'GET',
path: '/api/stats',
handler: this.handleGetStats.bind(this),
authLevel: 'admin',
description: 'Get MTA statistics'
});
// Documentation route
this.addRoute({
method: 'GET',
path: '/api',
handler: this.handleGetApiDocs.bind(this),
authLevel: 'none',
description: 'API documentation'
});
}
/**
* Add an API route
* @param route Route definition
*/
private addRoute(route: ApiRoute): void {
this.routes.push(route);
}
/**
* Check rate limit for a route
* @param route Route definition
* @param req Express request
* @returns Whether rate limit is exceeded
*/
private checkRateLimit(route: ApiRoute, req: any): boolean {
if (!route.rateLimit) return false;
const { maxRequests, windowMs, perEndpoint, perIp } = route.rateLimit;
// Determine rate limit key
let key = 'global';
if (perEndpoint) {
key = `${route.method}:${route.path}`;
}
// Get or create limiter
if (!this.rateLimiters.has(key)) {
this.rateLimiters.set(key, {
count: 0,
resetTime: Date.now() + windowMs,
clients: new Map()
});
}
const limiter = this.rateLimiters.get(key);
// Reset if window has passed
if (Date.now() > limiter.resetTime) {
limiter.count = 0;
limiter.resetTime = Date.now() + windowMs;
limiter.clients.clear();
}
// Check per-IP limit if enabled
if (perIp) {
const clientIp = req.socket.remoteAddress;
let clientLimiter = limiter.clients.get(clientIp);
if (!clientLimiter) {
clientLimiter = {
count: 0,
resetTime: Date.now() + windowMs
};
limiter.clients.set(clientIp, clientLimiter);
}
// Reset client limiter if needed
if (Date.now() > clientLimiter.resetTime) {
clientLimiter.count = 0;
clientLimiter.resetTime = Date.now() + windowMs;
}
// Check client limit
if (clientLimiter.count >= maxRequests) {
return true;
}
// Increment client count
clientLimiter.count++;
} else {
// Check global limit
if (limiter.count >= maxRequests) {
return true;
}
// Increment global count
limiter.count++;
}
return false;
}
/**
* Create an API error
* @param code Error code
* @param message Error message
* @param status HTTP status code
* @param details Additional details
* @returns API error
*/
private createError(code: string, message: string, status = 400, details?: any): Error & { code: string; status: number; details?: any } {
const error = new Error(message) as Error & { code: string; status: number; details?: any };
error.code = code;
error.status = status;
if (details) {
error.details = details;
}
return error;
}
/**
* Validate that MTA service is available
*/
private validateMtaService(): void {
if (!this.mtaRef) {
throw this.createError('SERVICE_UNAVAILABLE', 'MTA service is not available', 503);
}
}
/**
* Handle email send request
* @param req Express request
* @param res Express response
*/
private async handleSendEmail(req: any, res: any): Promise<void> {
this.validateMtaService();
const data = req.body as SendEmailRequest;
if (!data || !data.email) {
throw this.createError('INVALID_REQUEST', 'Missing email data');
}
try {
// Create Email instance
const email = new Email(data.email);
// Validate domains if requested
if (data.validateDomains) {
const fromDomain = email.getFromDomain();
if (fromDomain) {
const verification = await this.mtaRef.dnsManager.verifyEmailAuthRecords(fromDomain);
// Check if SPF and DKIM are valid
if (!verification.spf.valid || !verification.dkim.valid) {
throw this.createError('DOMAIN_VERIFICATION_FAILED', 'Domain DNS verification failed', 400, {
verification
});
}
}
}
// Send email
const id = await this.mtaRef.send(email);
// Return success response
res.json({
id,
message: 'Email queued successfully',
status: 'pending'
});
} catch (error) {
// Handle Email constructor errors
if (error.message.includes('Invalid') || error.message.includes('must have')) {
throw this.createError('INVALID_EMAIL', error.message);
}
throw error;
}
}
/**
* Handle email status request
* @param req Express request
* @param res Express response
*/
private async handleGetEmailStatus(req: any, res: any): Promise<void> {
this.validateMtaService();
const id = req.params.id;
if (!id) {
throw this.createError('INVALID_REQUEST', 'Missing email ID');
}
// Get email status
const status = this.mtaRef.getEmailStatus(id);
if (!status) {
throw this.createError('NOT_FOUND', `Email with ID ${id} not found`, 404);
}
// Create response
const response: EmailStatusResponse = {
id: status.id,
status: status.status,
sentAt: status.addedAt,
recipient: status.email.to[0],
attempts: status.attempts
};
// Add additional fields if available
if (status.lastAttempt) {
response.sentAt = status.lastAttempt;
}
if (status.status === DeliveryStatus.DELIVERED) {
response.deliveredAt = status.lastAttempt;
}
if (status.error) {
response.error = status.error.message;
}
if (status.nextAttempt) {
response.nextRetry = status.nextAttempt;
}
res.json(response);
}
/**
* Handle domain verification request
* @param req Express request
* @param res Express response
*/
private async handleVerifyDomain(req: any, res: any): Promise<void> {
this.validateMtaService();
const domain = req.params.domain;
if (!domain) {
throw this.createError('INVALID_REQUEST', 'Missing domain');
}
try {
// Verify domain DNS records
const records = await this.mtaRef.dnsManager.verifyEmailAuthRecords(domain);
// Get MX records
let mxValid = false;
let mxRecords: string[] = [];
let mxError: string = undefined;
try {
const mxResult = await this.mtaRef.dnsManager.lookupMx(domain);
mxValid = mxResult.length > 0;
mxRecords = mxResult.map(mx => mx.exchange);
} catch (error) {
mxError = error.message;
}
// Create response
const response: DomainVerificationResponse = {
domain,
verified: records.spf.valid && records.dkim.valid && records.dmarc.valid && mxValid,
details: {
spf: {
valid: records.spf.valid,
record: records.spf.value,
error: records.spf.error
},
dkim: {
valid: records.dkim.valid,
record: records.dkim.value,
error: records.dkim.error
},
dmarc: {
valid: records.dmarc.valid,
record: records.dmarc.value,
error: records.dmarc.error
},
mx: {
valid: mxValid,
records: mxRecords,
error: mxError
}
}
};
res.json(response);
} catch (error) {
throw this.createError('VERIFICATION_FAILED', `Domain verification failed: ${error.message}`);
}
}
/**
* Handle get domain records request
* @param req Express request
* @param res Express response
*/
private async handleGetDomainRecords(req: any, res: any): Promise<void> {
this.validateMtaService();
const domain = req.params.domain;
if (!domain) {
throw this.createError('INVALID_REQUEST', 'Missing domain');
}
try {
// Generate recommended DNS records
const records = await this.mtaRef.dnsManager.generateAllRecommendedRecords(domain);
res.json({
domain,
records
});
} catch (error) {
throw this.createError('GENERATION_FAILED', `DNS record generation failed: ${error.message}`);
}
}
/**
* Handle generate DKIM keys request
* @param req Express request
* @param res Express response
*/
private async handleGenerateDkim(req: any, res: any): Promise<void> {
this.validateMtaService();
const domain = req.params.domain;
if (!domain) {
throw this.createError('INVALID_REQUEST', 'Missing domain');
}
try {
// Generate DKIM keys
await this.mtaRef.dkimCreator.handleDKIMKeysForDomain(domain);
// Get DNS record
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
res.json({
domain,
dnsRecord,
message: 'DKIM keys generated successfully'
});
} catch (error) {
throw this.createError('GENERATION_FAILED', `DKIM generation failed: ${error.message}`);
}
}
/**
* Handle get DKIM public key request
* @param req Express request
* @param res Express response
*/
private async handleGetDkimPublicKey(req: any, res: any): Promise<void> {
this.validateMtaService();
const domain = req.params.domain;
if (!domain) {
throw this.createError('INVALID_REQUEST', 'Missing domain');
}
try {
// Get DKIM keys
const keys = await this.mtaRef.dkimCreator.readDKIMKeys(domain);
// Get DNS record
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
res.json({
domain,
publicKey: keys.publicKey,
dnsRecord
});
} catch (error) {
throw this.createError('NOT_FOUND', `DKIM keys not found for domain: ${domain}`, 404);
}
}
/**
* Handle get stats request
* @param req Express request
* @param res Express response
*/
private async handleGetStats(req: any, res: any): Promise<void> {
this.validateMtaService();
// Get MTA stats
const stats = this.mtaRef.getStats();
res.json(stats);
}
/**
* Handle get API docs request
* @param req Express request
* @param res Express response
*/
private async handleGetApiDocs(req: any, res: any): Promise<void> {
// Generate API documentation
const docs = {
name: 'MTA API',
version: '1.0.0',
description: 'API for interacting with the MTA service',
endpoints: this.routes.map(route => ({
method: route.method,
path: route.path,
description: route.description,
authLevel: route.authLevel
}))
};
res.json(docs);
}
/**
* Start the API server
* @param port Port to listen on
* @returns Promise that resolves when server is started
*/
public start(port: number = 3000): Promise<void> {
return new Promise((resolve, reject) => {
try {
// Start HTTP server
this.server.listen(port, () => {
console.log(`API server listening on port ${port}`);
resolve();
});
} catch (error) {
console.error('Failed to start API server:', error);
reject(error);
}
});
}
/**
* Stop the API server
*/
public stop(): void {
if (this.server) {
this.server.close();
console.log('API server stopped');
}
}
}

View File

@ -1,559 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { MtaService } from './mta.classes.mta.js';
/**
* Interface for DNS record information
*/
export interface IDnsRecord {
name: string;
type: string;
value: string;
ttl?: number;
dnsSecEnabled?: boolean;
}
/**
* Interface for DNS lookup options
*/
export interface IDnsLookupOptions {
/** Cache time to live in milliseconds, 0 to disable caching */
cacheTtl?: number;
/** Timeout for DNS queries in milliseconds */
timeout?: number;
}
/**
* Interface for DNS verification result
*/
export interface IDnsVerificationResult {
record: string;
found: boolean;
valid: boolean;
value?: string;
expectedValue?: string;
error?: string;
}
/**
* Manager for DNS-related operations, including record lookups, verification, and generation
*/
export class DNSManager {
public mtaRef: MtaService;
private cache: Map<string, { data: any; expires: number }> = new Map();
private defaultOptions: IDnsLookupOptions = {
cacheTtl: 300000, // 5 minutes
timeout: 5000 // 5 seconds
};
constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) {
this.mtaRef = mtaRefArg;
if (options) {
this.defaultOptions = {
...this.defaultOptions,
...options
};
}
// Ensure the DNS records directory exists
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
}
/**
* Lookup MX records for a domain
* @param domain Domain to look up
* @param options Lookup options
* @returns Array of MX records sorted by priority
*/
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
const lookupOptions = { ...this.defaultOptions, ...options };
const cacheKey = `mx:${domain}`;
// Check cache first
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
if (cached) {
return cached;
}
try {
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
// Sort by priority
records.sort((a, b) => a.priority - b.priority);
// Cache the result
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
return records;
} catch (error) {
console.error(`Error looking up MX records for ${domain}:`, error);
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
}
}
/**
* Lookup TXT records for a domain
* @param domain Domain to look up
* @param options Lookup options
* @returns Array of TXT records
*/
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
const lookupOptions = { ...this.defaultOptions, ...options };
const cacheKey = `txt:${domain}`;
// Check cache first
const cached = this.getFromCache<string[][]>(cacheKey);
if (cached) {
return cached;
}
try {
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
// Cache the result
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
return records;
} catch (error) {
console.error(`Error looking up TXT records for ${domain}:`, error);
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
}
}
/**
* Find specific TXT record by subdomain and prefix
* @param domain Base domain
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
* @param prefix Record prefix to match (e.g., "v=DKIM1")
* @param options Lookup options
* @returns Matching TXT record or null if not found
*/
public async findTxtRecord(
domain: string,
subdomain: string = '',
prefix: string = '',
options?: IDnsLookupOptions
): Promise<string | null> {
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
try {
const records = await this.lookupTxt(fullDomain, options);
for (const recordArray of records) {
// TXT records can be split into chunks, join them
const record = recordArray.join('');
if (!prefix || record.startsWith(prefix)) {
return record;
}
}
return null;
} catch (error) {
// Domain might not exist or no TXT records
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
return null;
}
}
/**
* Verify if a domain has a valid SPF record
* @param domain Domain to verify
* @returns Verification result
*/
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
const result: IDnsVerificationResult = {
record: 'SPF',
found: false,
valid: false
};
try {
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
if (spfRecord) {
result.found = true;
result.value = spfRecord;
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
result.valid = isValid;
if (!isValid) {
result.error = 'SPF record format is invalid';
}
} else {
result.error = 'No SPF record found';
}
} catch (error) {
result.error = `Error verifying SPF: ${error.message}`;
}
return result;
}
/**
* Verify if a domain has a valid DKIM record
* @param domain Domain to verify
* @param selector DKIM selector (usually "mta" in our case)
* @returns Verification result
*/
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
const result: IDnsVerificationResult = {
record: 'DKIM',
found: false,
valid: false
};
try {
const dkimSelector = `${selector}._domainkey`;
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
if (dkimRecord) {
result.found = true;
result.value = dkimRecord;
// Basic validation - check for required fields
const hasP = dkimRecord.includes('p=');
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
if (!result.valid) {
result.error = 'DKIM record is missing required fields';
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
result.valid = false;
result.error = 'DKIM record has invalid public key format';
}
} else {
result.error = `No DKIM record found for selector ${selector}`;
}
} catch (error) {
result.error = `Error verifying DKIM: ${error.message}`;
}
return result;
}
/**
* Verify if a domain has a valid DMARC record
* @param domain Domain to verify
* @returns Verification result
*/
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
const result: IDnsVerificationResult = {
record: 'DMARC',
found: false,
valid: false
};
try {
const dmarcDomain = `_dmarc.${domain}`;
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
if (dmarcRecord) {
result.found = true;
result.value = dmarcRecord;
// Basic validation - check for required fields
const hasPolicy = dmarcRecord.includes('p=');
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
if (!result.valid) {
result.error = 'DMARC record is missing required fields';
}
} else {
result.error = 'No DMARC record found';
}
} catch (error) {
result.error = `Error verifying DMARC: ${error.message}`;
}
return result;
}
/**
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
* @param domain Domain to check
* @param dkimSelector DKIM selector
* @returns Object with verification results for each record type
*/
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
spf: IDnsVerificationResult;
dkim: IDnsVerificationResult;
dmarc: IDnsVerificationResult;
}> {
const [spf, dkim, dmarc] = await Promise.all([
this.verifySpfRecord(domain),
this.verifyDkimRecord(domain, dkimSelector),
this.verifyDmarcRecord(domain)
]);
return { spf, dkim, dmarc };
}
/**
* Generate a recommended SPF record for a domain
* @param domain Domain name
* @param options Configuration options for the SPF record
* @returns Generated SPF record
*/
public generateSpfRecord(domain: string, options: {
includeMx?: boolean;
includeA?: boolean;
includeIps?: string[];
includeSpf?: string[];
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
} = {}): IDnsRecord {
const {
includeMx = true,
includeA = true,
includeIps = [],
includeSpf = [],
policy = 'softfail'
} = options;
let value = 'v=spf1';
if (includeMx) {
value += ' mx';
}
if (includeA) {
value += ' a';
}
// Add IP addresses
for (const ip of includeIps) {
if (ip.includes(':')) {
value += ` ip6:${ip}`;
} else {
value += ` ip4:${ip}`;
}
}
// Add includes
for (const include of includeSpf) {
value += ` include:${include}`;
}
// Add policy
const policyMap = {
'none': '?all',
'neutral': '~all',
'softfail': '~all',
'fail': '-all',
'reject': '-all'
};
value += ` ${policyMap[policy]}`;
return {
name: domain,
type: 'TXT',
value: value
};
}
/**
* Generate a recommended DMARC record for a domain
* @param domain Domain name
* @param options Configuration options for the DMARC record
* @returns Generated DMARC record
*/
public generateDmarcRecord(domain: string, options: {
policy?: 'none' | 'quarantine' | 'reject';
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
pct?: number;
rua?: string;
ruf?: string;
daysInterval?: number;
} = {}): IDnsRecord {
const {
policy = 'none',
subdomainPolicy,
pct = 100,
rua,
ruf,
daysInterval = 1
} = options;
let value = 'v=DMARC1; p=' + policy;
if (subdomainPolicy) {
value += `; sp=${subdomainPolicy}`;
}
if (pct !== 100) {
value += `; pct=${pct}`;
}
if (rua) {
value += `; rua=mailto:${rua}`;
}
if (ruf) {
value += `; ruf=mailto:${ruf}`;
}
if (daysInterval !== 1) {
value += `; ri=${daysInterval * 86400}`;
}
// Add reporting format and ADKIM/ASPF alignment
value += '; fo=1; adkim=r; aspf=r';
return {
name: `_dmarc.${domain}`,
type: 'TXT',
value: value
};
}
/**
* Save DNS record recommendations to a file
* @param domain Domain name
* @param records DNS records to save
*/
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
try {
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath);
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
} catch (error) {
console.error(`Error saving DNS recommendations for ${domain}:`, error);
}
}
/**
* Get cache key value
* @param key Cache key
* @returns Cached value or undefined if not found or expired
*/
private getFromCache<T>(key: string): T | undefined {
const cached = this.cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.data as T;
}
// Remove expired entry
if (cached) {
this.cache.delete(key);
}
return undefined;
}
/**
* Set cache key value
* @param key Cache key
* @param data Data to cache
* @param ttl TTL in milliseconds
*/
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
if (ttl <= 0) return; // Don't cache if TTL is disabled
this.cache.set(key, {
data,
expires: Date.now() + ttl
});
}
/**
* Clear the DNS cache
* @param key Optional specific key to clear, or all cache if not provided
*/
public clearCache(key?: string): void {
if (key) {
this.cache.delete(key);
} else {
this.cache.clear();
}
}
/**
* Promise-based wrapper for dns.resolveMx
* @param domain Domain to resolve
* @param timeout Timeout in milliseconds
* @returns Promise resolving to MX records
*/
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`DNS MX lookup timeout for ${domain}`));
}, timeout);
plugins.dns.resolveMx(domain, (err, addresses) => {
clearTimeout(timeoutId);
if (err) {
reject(err);
} else {
resolve(addresses);
}
});
});
}
/**
* Promise-based wrapper for dns.resolveTxt
* @param domain Domain to resolve
* @param timeout Timeout in milliseconds
* @returns Promise resolving to TXT records
*/
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
}, timeout);
plugins.dns.resolveTxt(domain, (err, records) => {
clearTimeout(timeoutId);
if (err) {
reject(err);
} else {
resolve(records);
}
});
});
}
/**
* Generate all recommended DNS records for proper email authentication
* @param domain Domain to generate records for
* @returns Array of recommended DNS records
*/
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
const records: IDnsRecord[] = [];
// Get DKIM record (already created by DKIMCreator)
try {
// Now using the public method
const dkimRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
records.push(dkimRecord);
} catch (error) {
console.error(`Error getting DKIM record for ${domain}:`, error);
}
// Generate SPF record
const spfRecord = this.generateSpfRecord(domain, {
includeMx: true,
includeA: true,
policy: 'softfail'
});
records.push(spfRecord);
// Generate DMARC record
const dmarcRecord = this.generateDmarcRecord(domain, {
policy: 'none', // Start with monitoring mode
rua: `dmarc@${domain}` // Replace with appropriate report address
});
records.push(dmarcRecord);
// Save recommendations
await this.saveDnsRecommendations(domain, records);
return records;
}
}

View File

@ -1,219 +0,0 @@
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;
}
}

View File

@ -1,623 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './classes.email.js';
import { EmailSignJob } from './classes.emailsignjob.js';
import type { MtaService } from './classes.mta.js';
// Configuration options for email sending
export interface IEmailSendOptions {
maxRetries?: number;
retryDelay?: number; // in milliseconds
connectionTimeout?: number; // in milliseconds
tlsOptions?: plugins.tls.ConnectionOptions;
debugMode?: boolean;
}
// Email delivery status
export enum DeliveryStatus {
PENDING = 'pending',
SENDING = 'sending',
DELIVERED = 'delivered',
FAILED = 'failed',
DEFERRED = 'deferred' // Temporary failure, will retry
}
// Detailed information about delivery attempts
export interface DeliveryInfo {
status: DeliveryStatus;
attempts: number;
error?: Error;
lastAttempt?: Date;
nextAttempt?: Date;
mxServer?: string;
deliveryTime?: Date;
logs: string[];
}
export class EmailSendJob {
mtaRef: MtaService;
private email: Email;
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
private mxServers: string[] = [];
private currentMxIndex = 0;
private options: IEmailSendOptions;
public deliveryInfo: DeliveryInfo;
constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) {
this.email = emailArg;
this.mtaRef = mtaRef;
// Set default options
this.options = {
maxRetries: options.maxRetries || 3,
retryDelay: options.retryDelay || 300000, // 5 minutes
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
debugMode: options.debugMode || false
};
// Initialize delivery info
this.deliveryInfo = {
status: DeliveryStatus.PENDING,
attempts: 0,
logs: []
};
}
/**
* Send the email with retry logic
*/
async send(): Promise<DeliveryStatus> {
try {
// Check if the email is valid before attempting to send
this.validateEmail();
// Resolve MX records for the recipient domain
await this.resolveMxRecords();
// Try to send the email
return await this.attemptDelivery();
} catch (error) {
this.log(`Critical error in send process: ${error.message}`);
this.deliveryInfo.status = DeliveryStatus.FAILED;
this.deliveryInfo.error = error;
// Save failed email for potential future retry or analysis
await this.saveFailed();
return DeliveryStatus.FAILED;
}
}
/**
* Validate the email before sending
*/
private validateEmail(): void {
if (!this.email.to || this.email.to.length === 0) {
throw new Error('No recipients specified');
}
if (!this.email.from) {
throw new Error('No sender specified');
}
const fromDomain = this.email.getFromDomain();
if (!fromDomain) {
throw new Error('Invalid sender domain');
}
}
/**
* Resolve MX records for the recipient domain
*/
private async resolveMxRecords(): Promise<void> {
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
if (!domain) {
throw new Error('Invalid recipient domain');
}
this.log(`Resolving MX records for domain: ${domain}`);
try {
const addresses = await this.resolveMx(domain);
// Sort by priority (lowest number = highest priority)
addresses.sort((a, b) => a.priority - b.priority);
this.mxServers = addresses.map(mx => mx.exchange);
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
if (this.mxServers.length === 0) {
throw new Error(`No MX records found for domain: ${domain}`);
}
} catch (error) {
this.log(`Failed to resolve MX records: ${error.message}`);
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
}
}
/**
* Attempt to deliver the email with retries
*/
private async attemptDelivery(): Promise<DeliveryStatus> {
while (this.deliveryInfo.attempts < this.options.maxRetries) {
this.deliveryInfo.attempts++;
this.deliveryInfo.lastAttempt = new Date();
this.deliveryInfo.status = DeliveryStatus.SENDING;
try {
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
// Try each MX server in order of priority
while (this.currentMxIndex < this.mxServers.length) {
const currentMx = this.mxServers[this.currentMxIndex];
this.deliveryInfo.mxServer = currentMx;
try {
this.log(`Attempting delivery to MX server: ${currentMx}`);
await this.connectAndSend(currentMx);
// If we get here, email was sent successfully
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
this.deliveryInfo.deliveryTime = new Date();
this.log(`Email delivered successfully to ${currentMx}`);
// 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));
}
}

View File

@ -1,945 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './classes.email.js';
import { EmailSendJob, DeliveryStatus } from './classes.emailsendjob.js';
import { DKIMCreator } from './classes.dkimcreator.js';
import { DKIMVerifier } from './classes.dkimverifier.js';
import { SMTPServer, type ISmtpServerOptions } from './classes.smtpserver.js';
import { DNSManager } from './classes.dnsmanager.js';
import { ApiManager } from './classes.apimanager.js';
import type { SzPlatformService } from '../platformservice.js';
/**
* Configuration options for the MTA service
*/
export interface IMtaConfig {
/** SMTP server options */
smtp?: {
/** Whether to enable the SMTP server */
enabled?: boolean;
/** Port to listen on (default: 25) */
port?: number;
/** SMTP server hostname */
hostname?: string;
/** Maximum allowed email size in bytes */
maxSize?: number;
};
/** SSL/TLS configuration */
tls?: {
/** Domain for certificate */
domain?: string;
/** Whether to auto-renew certificates */
autoRenew?: boolean;
/** Custom key/cert paths (if not using auto-provision) */
keyPath?: string;
certPath?: string;
};
/** Outbound email sending configuration */
outbound?: {
/** Maximum concurrent sending jobs */
concurrency?: number;
/** Retry configuration */
retries?: {
/** Maximum number of retries per message */
max?: number;
/** Initial delay between retries (milliseconds) */
delay?: number;
/** Whether to use exponential backoff for retries */
useBackoff?: boolean;
};
/** Rate limiting configuration */
rateLimit?: {
/** Maximum emails per period */
maxPerPeriod?: number;
/** Time period in milliseconds */
periodMs?: number;
/** Whether to apply per domain (vs globally) */
perDomain?: boolean;
};
};
/** Security settings */
security?: {
/** Whether to use DKIM signing */
useDkim?: boolean;
/** Whether to verify inbound DKIM signatures */
verifyDkim?: boolean;
/** Whether to verify SPF on inbound */
verifySpf?: boolean;
/** Whether to use TLS for outbound when available */
useTls?: boolean;
/** Whether to require valid certificates */
requireValidCerts?: boolean;
};
/** Domains configuration */
domains?: {
/** List of domains that this MTA will handle as local */
local?: string[];
/** Whether to auto-create DNS records */
autoCreateDnsRecords?: boolean;
/** DKIM selector to use (default: "mta") */
dkimSelector?: string;
};
}
/**
* Email queue entry
*/
interface QueueEntry {
id: string;
email: Email;
addedAt: Date;
processing: boolean;
attempts: number;
lastAttempt?: Date;
nextAttempt?: Date;
error?: Error;
status: DeliveryStatus;
}
/**
* Certificate information
*/
interface Certificate {
privateKey: string;
publicKey: string;
expiresAt: Date;
}
/**
* Stats for MTA monitoring
*/
interface MtaStats {
startTime: Date;
emailsReceived: number;
emailsSent: number;
emailsFailed: number;
activeConnections: number;
queueSize: number;
certificateInfo?: {
domain: string;
expiresAt: Date;
daysUntilExpiry: number;
};
}
/**
* Main MTA Service class that coordinates all email functionality
*/
export class MtaService {
/** Reference to the platform service */
public platformServiceRef: SzPlatformService;
/** SMTP server instance */
public server: SMTPServer;
/** DKIM creator for signing outgoing emails */
public dkimCreator: DKIMCreator;
/** DKIM verifier for validating incoming emails */
public dkimVerifier: DKIMVerifier;
/** DNS manager for handling DNS records */
public dnsManager: DNSManager;
/** API manager for external integrations */
public apiManager: ApiManager;
/** Email queue for outbound emails */
private emailQueue: Map<string, QueueEntry> = new Map();
/** Email queue processing state */
private queueProcessing = false;
/** Rate limiters for outbound emails */
private rateLimiters: Map<string, {
tokens: number;
lastRefill: number;
}> = new Map();
/** Certificate cache */
private certificate: Certificate = null;
/** MTA configuration */
private config: IMtaConfig;
/** Stats for monitoring */
private stats: MtaStats;
/** Whether the service is currently running */
private running = false;
/**
* Initialize the MTA service
* @param platformServiceRefArg Reference to the platform service
* @param config Configuration options
*/
constructor(platformServiceRefArg: SzPlatformService, config: IMtaConfig = {}) {
this.platformServiceRef = platformServiceRefArg;
// Initialize with default configuration
this.config = this.getDefaultConfig();
// Merge with provided configuration
this.config = this.mergeConfig(this.config, config);
// Initialize components
this.dkimCreator = new DKIMCreator(this);
this.dkimVerifier = new DKIMVerifier(this);
this.dnsManager = new DNSManager(this);
this.apiManager = new ApiManager();
// Initialize stats
this.stats = {
startTime: new Date(),
emailsReceived: 0,
emailsSent: 0,
emailsFailed: 0,
activeConnections: 0,
queueSize: 0
};
// Ensure required directories exist
this.ensureDirectories();
}
/**
* Get default configuration
*/
private getDefaultConfig(): IMtaConfig {
return {
smtp: {
enabled: true,
port: 25,
hostname: 'mta.lossless.one',
maxSize: 10 * 1024 * 1024 // 10MB
},
tls: {
domain: 'mta.lossless.one',
autoRenew: true
},
outbound: {
concurrency: 5,
retries: {
max: 3,
delay: 300000, // 5 minutes
useBackoff: true
},
rateLimit: {
maxPerPeriod: 100,
periodMs: 60000, // 1 minute
perDomain: true
}
},
security: {
useDkim: true,
verifyDkim: true,
verifySpf: true,
useTls: true,
requireValidCerts: false
},
domains: {
local: ['lossless.one'],
autoCreateDnsRecords: true,
dkimSelector: 'mta'
}
};
}
/**
* Merge configurations
*/
private mergeConfig(defaultConfig: IMtaConfig, customConfig: IMtaConfig): IMtaConfig {
// Deep merge of configurations
// (A more robust implementation would use a dedicated deep-merge library)
const merged = { ...defaultConfig };
// Merge first level
for (const [key, value] of Object.entries(customConfig)) {
if (value === null || value === undefined) continue;
if (typeof value === 'object' && !Array.isArray(value)) {
merged[key] = { ...merged[key], ...value };
} else {
merged[key] = value;
}
}
return merged;
}
/**
* Ensure required directories exist
*/
private ensureDirectories(): void {
plugins.smartfile.fs.ensureDirSync(paths.keysDir);
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
plugins.smartfile.fs.ensureDirSync(paths.logsDir);
}
/**
* Start the MTA service
*/
public async start(): Promise<void> {
if (this.running) {
console.warn('MTA service is already running');
return;
}
try {
console.log('Starting MTA service...');
// Load or provision certificate
await this.loadOrProvisionCertificate();
// Start SMTP server if enabled
if (this.config.smtp.enabled) {
const smtpOptions: ISmtpServerOptions = {
port: this.config.smtp.port,
key: this.certificate.privateKey,
cert: this.certificate.publicKey,
hostname: this.config.smtp.hostname
};
this.server = new SMTPServer(this, smtpOptions);
this.server.start();
console.log(`SMTP server started on port ${smtpOptions.port}`);
}
// Start queue processing
this.startQueueProcessing();
// Update DNS records for local domains if configured
if (this.config.domains.autoCreateDnsRecords) {
await this.updateDnsRecordsForLocalDomains();
}
this.running = true;
console.log('MTA service started successfully');
} catch (error) {
console.error('Failed to start MTA service:', error);
throw error;
}
}
/**
* Stop the MTA service
*/
public async stop(): Promise<void> {
if (!this.running) {
console.warn('MTA service is not running');
return;
}
try {
console.log('Stopping MTA service...');
// Stop SMTP server if running
if (this.server) {
await this.server.stop();
this.server = null;
console.log('SMTP server stopped');
}
// Stop queue processing
this.queueProcessing = false;
console.log('Email queue processing stopped');
this.running = false;
console.log('MTA service stopped successfully');
} catch (error) {
console.error('Error stopping MTA service:', error);
throw error;
}
}
/**
* Send an email (add to queue)
*/
public async send(email: Email): Promise<string> {
if (!this.running) {
throw new Error('MTA service is not running');
}
// Generate a unique ID for this email
const id = plugins.uuid.v4();
// Validate email
this.validateEmail(email);
// Create DKIM keys if needed
if (this.config.security.useDkim) {
await this.dkimCreator.handleDKIMKeysForEmail(email);
}
// Add to queue
this.emailQueue.set(id, {
id,
email,
addedAt: new Date(),
processing: false,
attempts: 0,
status: DeliveryStatus.PENDING
});
// Update stats
this.stats.queueSize = this.emailQueue.size;
console.log(`Email added to queue: ${id}`);
return id;
}
/**
* Get status of an email in the queue
*/
public getEmailStatus(id: string): QueueEntry | null {
return this.emailQueue.get(id) || null;
}
/**
* Handle an incoming email
*/
public async processIncomingEmail(email: Email): Promise<boolean> {
if (!this.running) {
throw new Error('MTA service is not running');
}
try {
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
// Update stats
this.stats.emailsReceived++;
// Check if the recipient domain is local
const recipientDomain = email.to[0].split('@')[1];
const isLocalDomain = this.isLocalDomain(recipientDomain);
if (isLocalDomain) {
// Save to local mailbox
await this.saveToLocalMailbox(email);
return true;
} else {
// Forward to another server
const forwardId = await this.send(email);
console.log(`Forwarding email to ${email.to} with queue ID ${forwardId}`);
return true;
}
} catch (error) {
console.error('Error processing incoming email:', error);
return false;
}
}
/**
* Check if a domain is local
*/
private isLocalDomain(domain: string): boolean {
return this.config.domains.local.includes(domain);
}
/**
* Save an email to a local mailbox
*/
private async saveToLocalMailbox(email: Email): Promise<void> {
// Simplified implementation - in a real system, this would store to a user's mailbox
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
plugins.smartfile.fs.ensureDirSync(mailboxPath);
const emailContent = email.toRFC822String();
const filename = `${Date.now()}_${email.to[0].replace('@', '_at_')}.eml`;
plugins.smartfile.memory.toFsSync(
emailContent,
plugins.path.join(mailboxPath, filename)
);
console.log(`Email saved to local mailbox: ${filename}`);
}
/**
* Start processing the email queue
*/
private startQueueProcessing(): void {
if (this.queueProcessing) return;
this.queueProcessing = true;
this.processQueue();
console.log('Email queue processing started');
}
/**
* Process emails in the queue
*/
private async processQueue(): Promise<void> {
if (!this.queueProcessing) return;
try {
// Get pending emails ordered by next attempt time
const pendingEmails = Array.from(this.emailQueue.values())
.filter(entry =>
(entry.status === DeliveryStatus.PENDING || entry.status === DeliveryStatus.DEFERRED) &&
!entry.processing &&
(!entry.nextAttempt || entry.nextAttempt <= new Date())
)
.sort((a, b) => {
// Sort by next attempt time, then by added time
if (a.nextAttempt && b.nextAttempt) {
return a.nextAttempt.getTime() - b.nextAttempt.getTime();
} else if (a.nextAttempt) {
return 1;
} else if (b.nextAttempt) {
return -1;
} else {
return a.addedAt.getTime() - b.addedAt.getTime();
}
});
// Determine how many emails we can process concurrently
const availableSlots = Math.max(0, this.config.outbound.concurrency -
Array.from(this.emailQueue.values()).filter(e => e.processing).length);
// Process emails up to our concurrency limit
for (let i = 0; i < Math.min(availableSlots, pendingEmails.length); i++) {
const entry = pendingEmails[i];
// Check rate limits
if (!this.checkRateLimit(entry.email)) {
continue;
}
// Mark as processing
entry.processing = true;
// Process in background
this.processQueueEntry(entry).catch(error => {
console.error(`Error processing queue entry ${entry.id}:`, error);
});
}
} catch (error) {
console.error('Error in queue processing:', error);
} finally {
// Schedule next processing cycle
setTimeout(() => this.processQueue(), 1000);
}
}
/**
* Process a single queue entry
*/
private async processQueueEntry(entry: QueueEntry): Promise<void> {
try {
console.log(`Processing queue entry ${entry.id}`);
// Update attempt counters
entry.attempts++;
entry.lastAttempt = new Date();
// Create send job
const sendJob = new EmailSendJob(this, entry.email, {
maxRetries: 1, // We handle retries at the queue level
tlsOptions: {
rejectUnauthorized: this.config.security.requireValidCerts
}
});
// Send the email
const status = await sendJob.send();
entry.status = status;
if (status === DeliveryStatus.DELIVERED) {
// Success - remove from queue
this.emailQueue.delete(entry.id);
this.stats.emailsSent++;
console.log(`Email ${entry.id} delivered successfully`);
} else if (status === DeliveryStatus.FAILED) {
// Permanent failure
entry.error = sendJob.deliveryInfo.error;
this.stats.emailsFailed++;
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
// Remove from queue
this.emailQueue.delete(entry.id);
} else if (status === DeliveryStatus.DEFERRED) {
// Temporary failure - schedule retry if attempts remain
entry.error = sendJob.deliveryInfo.error;
if (entry.attempts >= this.config.outbound.retries.max) {
// Max retries reached - mark as failed
entry.status = DeliveryStatus.FAILED;
this.stats.emailsFailed++;
console.log(`Email ${entry.id} failed after ${entry.attempts} attempts: ${entry.error.message}`);
// Remove from queue
this.emailQueue.delete(entry.id);
} else {
// Schedule retry
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
console.log(`Email ${entry.id} deferred, next attempt at ${entry.nextAttempt}`);
}
}
} catch (error) {
console.error(`Unexpected error processing queue entry ${entry.id}:`, error);
// Handle unexpected errors similarly to deferred
entry.error = error;
if (entry.attempts >= this.config.outbound.retries.max) {
entry.status = DeliveryStatus.FAILED;
this.stats.emailsFailed++;
this.emailQueue.delete(entry.id);
} else {
entry.status = DeliveryStatus.DEFERRED;
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
}
} finally {
// Mark as no longer processing
entry.processing = false;
// Update stats
this.stats.queueSize = this.emailQueue.size;
}
}
/**
* Calculate delay before retry based on attempt number
*/
private calculateRetryDelay(attemptNumber: number): number {
const baseDelay = this.config.outbound.retries.delay;
if (this.config.outbound.retries.useBackoff) {
// Exponential backoff: base_delay * (2^(attempt-1))
return baseDelay * Math.pow(2, attemptNumber - 1);
} else {
return baseDelay;
}
}
/**
* Check if an email can be sent under rate limits
*/
private checkRateLimit(email: Email): boolean {
const config = this.config.outbound.rateLimit;
if (!config || !config.maxPerPeriod) {
return true; // No rate limit configured
}
// Determine which limiter to use
const key = config.perDomain ? email.getFromDomain() : 'global';
// Initialize limiter if needed
if (!this.rateLimiters.has(key)) {
this.rateLimiters.set(key, {
tokens: config.maxPerPeriod,
lastRefill: Date.now()
});
}
const limiter = this.rateLimiters.get(key);
// Refill tokens based on time elapsed
const now = Date.now();
const elapsedMs = now - limiter.lastRefill;
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
if (tokensToAdd > 0) {
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
limiter.lastRefill = now - (elapsedMs % config.periodMs);
}
// Check if we have tokens available
if (limiter.tokens > 0) {
limiter.tokens--;
return true;
} else {
console.log(`Rate limit exceeded for ${key}`);
return false;
}
}
/**
* Load or provision a TLS certificate
*/
private async loadOrProvisionCertificate(): Promise<void> {
try {
// Check if we have manual cert paths specified
if (this.config.tls.keyPath && this.config.tls.certPath) {
console.log('Using manually specified certificate files');
const [privateKey, publicKey] = await Promise.all([
plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf-8'),
plugins.fs.promises.readFile(this.config.tls.certPath, 'utf-8')
]);
this.certificate = {
privateKey,
publicKey,
expiresAt: this.getCertificateExpiry(publicKey)
};
console.log(`Certificate loaded, expires: ${this.certificate.expiresAt}`);
return;
}
// Otherwise, use auto-provisioning
console.log(`Provisioning certificate for ${this.config.tls.domain}`);
this.certificate = await this.provisionCertificate(this.config.tls.domain);
console.log(`Certificate provisioned, expires: ${this.certificate.expiresAt}`);
// Set up auto-renewal if configured
if (this.config.tls.autoRenew) {
this.setupCertificateRenewal();
}
} catch (error) {
console.error('Error loading or provisioning certificate:', error);
throw error;
}
}
/**
* Provision a certificate from the certificate service
*/
private async provisionCertificate(domain: string): Promise<Certificate> {
try {
// Setup proper authentication
const authToken = await this.getAuthToken();
if (!authToken) {
throw new Error('Failed to obtain authentication token for certificate provisioning');
}
// Initialize client
const typedrouter = new plugins.typedrequest.TypedRouter();
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
typedrouter,
'https://cloudly.lossless.one:443'
);
try {
// Request certificate
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
const typedResponse = await typedCertificateRequest.fire({
authToken,
requiredCertName: domain,
});
if (!typedResponse || !typedResponse.certificate) {
throw new Error('Invalid response from certificate service');
}
// Extract certificate information
const cert = typedResponse.certificate;
// Determine expiry date
const expiresAt = this.getCertificateExpiry(cert.publicKey);
return {
privateKey: cert.privateKey,
publicKey: cert.publicKey,
expiresAt
};
} finally {
// Always close the client
await typedsocketClient.stop();
}
} catch (error) {
console.error('Certificate provisioning failed:', error);
throw error;
}
}
/**
* Get authentication token for certificate service
*/
private async getAuthToken(): Promise<string> {
// Implementation would depend on authentication mechanism
// This is a simplified example assuming the platform service has an auth method
try {
// For now, return a placeholder token - in production this would
// authenticate properly with the certificate service
return 'mta-service-auth-token';
} catch (error) {
console.error('Failed to obtain auth token:', error);
return null;
}
}
/**
* Extract certificate expiry date from public key
*/
private getCertificateExpiry(publicKey: string): Date {
try {
// This is a simplified implementation
// In a real system, you would parse the certificate properly
// using a certificate parsing library
// For now, set expiry to 90 days from now
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 90);
return expiresAt;
} catch (error) {
console.error('Failed to extract certificate expiry:', error);
// Default to 30 days from now
const defaultExpiry = new Date();
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
return defaultExpiry;
}
}
/**
* Set up certificate auto-renewal
*/
private setupCertificateRenewal(): void {
if (!this.certificate || !this.certificate.expiresAt) {
console.warn('Cannot setup certificate renewal: no valid certificate');
return;
}
// Calculate time until renewal (30 days before expiry)
const now = new Date();
const renewalDate = new Date(this.certificate.expiresAt);
renewalDate.setDate(renewalDate.getDate() - 30);
const timeUntilRenewal = Math.max(0, renewalDate.getTime() - now.getTime());
console.log(`Certificate renewal scheduled for ${renewalDate}`);
// Schedule renewal
setTimeout(() => {
this.renewCertificate().catch(error => {
console.error('Certificate renewal failed:', error);
});
}, timeUntilRenewal);
}
/**
* Renew the certificate
*/
private async renewCertificate(): Promise<void> {
try {
console.log('Renewing certificate...');
// Provision new certificate
const newCertificate = await this.provisionCertificate(this.config.tls.domain);
// Replace current certificate
this.certificate = newCertificate;
console.log(`Certificate renewed, new expiry: ${newCertificate.expiresAt}`);
// Update SMTP server with new certificate if running
if (this.server) {
// Restart server with new certificate
await this.server.stop();
const smtpOptions: ISmtpServerOptions = {
port: this.config.smtp.port,
key: this.certificate.privateKey,
cert: this.certificate.publicKey,
hostname: this.config.smtp.hostname
};
this.server = new SMTPServer(this, smtpOptions);
this.server.start();
console.log('SMTP server restarted with new certificate');
}
// Schedule next renewal
this.setupCertificateRenewal();
} catch (error) {
console.error('Certificate renewal failed:', error);
// Schedule retry after 24 hours
setTimeout(() => {
this.renewCertificate().catch(err => {
console.error('Certificate renewal retry failed:', err);
});
}, 24 * 60 * 60 * 1000);
}
}
/**
* Update DNS records for all local domains
*/
private async updateDnsRecordsForLocalDomains(): Promise<void> {
if (!this.config.domains.local || this.config.domains.local.length === 0) {
return;
}
console.log('Updating DNS records for local domains...');
for (const domain of this.config.domains.local) {
try {
console.log(`Updating DNS records for ${domain}`);
// Generate DKIM keys if needed
await this.dkimCreator.handleDKIMKeysForDomain(domain);
// Generate all recommended DNS records
const records = await this.dnsManager.generateAllRecommendedRecords(domain);
console.log(`Generated ${records.length} DNS records for ${domain}`);
} catch (error) {
console.error(`Error updating DNS records for ${domain}:`, error);
}
}
}
/**
* Validate an email before sending
*/
private validateEmail(email: Email): void {
// The Email class constructor already performs basic validation
// Here we can add additional MTA-specific validation
if (!email.from) {
throw new Error('Email must have a sender address');
}
if (!email.to || email.to.length === 0) {
throw new Error('Email must have at least one recipient');
}
// Check if the sender domain is allowed
const senderDomain = email.getFromDomain();
if (!senderDomain) {
throw new Error('Invalid sender domain');
}
// If the sender domain is one of our local domains, ensure we have DKIM keys
if (this.isLocalDomain(senderDomain) && this.config.security.useDkim) {
// DKIM keys will be created if needed in the send method
}
}
/**
* Get MTA service statistics
*/
public getStats(): MtaStats {
// Update queue size
this.stats.queueSize = this.emailQueue.size;
// Update certificate info if available
if (this.certificate) {
const now = new Date();
const daysUntilExpiry = Math.floor(
(this.certificate.expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
this.stats.certificateInfo = {
domain: this.config.tls.domain,
expiresAt: this.certificate.expiresAt,
daysUntilExpiry
};
}
return { ...this.stats };
}
}

View File

@ -1,476 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './classes.email.js';
import type { MtaService } from './classes.mta.js';
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');
});
}
}

View File

@ -1,7 +1,8 @@
export * from './classes.dkimcreator.js';
export * from './classes.emailsignjob.js';
export * from './classes.dkimverifier.js';
export * from './classes.mta.js';
export * from './classes.smtpserver.js';
export * from './classes.emailsendjob.js';
export * from './classes.email.js';
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';

View File

@ -0,0 +1,7 @@
import * as plugins from '../plugins.js';
export class ApiManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
}

View File

@ -1,8 +1,8 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './classes.email.js';
import type { MtaService } from './classes.mta.js';
import { Email } from './mta.classes.email.js';
import type { MTA } from './mta.classes.mta.js';
const readFile = plugins.util.promisify(plugins.fs.readFile);
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
@ -16,7 +16,7 @@ export interface IKeyPaths {
export class DKIMCreator {
private keysDir: string;
constructor(private metaRef: MtaService, keysDir = paths.keysDir) {
constructor(metaRef: MTA, keysDir = paths.keysDir) {
this.keysDir = keysDir;
}
@ -60,8 +60,8 @@ export class DKIMCreator {
return { privateKey, publicKey };
}
// Create a DKIM key pair - changed to public for API access
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
// 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' },
@ -71,8 +71,8 @@ export class DKIMCreator {
return { privateKey, publicKey };
}
// Store a DKIM key pair to disk - changed to public for API access
public async storeDKIMKeys(
// Store a DKIM key pair to disk
private async storeDKIMKeys(
privateKey: string,
publicKey: string,
privateKeyPath: string,
@ -81,8 +81,8 @@ export class DKIMCreator {
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
}
// Create a DKIM key pair and store it to disk - changed to public for API access
public async createAndStoreDKIMKeys(domain: string): Promise<void> {
// 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(
@ -94,8 +94,7 @@ export class DKIMCreator {
console.log(`DKIM keys for ${domain} created and stored.`);
}
// Changed to public for API access
public async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
private async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
await this.handleDKIMKeysForDomain(domainArg);
const keys = await this.readDKIMKeys(domainArg);
@ -117,4 +116,4 @@ export class DKIMCreator {
value: dnsRecordValue,
};
}
}
}

View File

@ -1,10 +1,10 @@
import * as plugins from '../plugins.js';
import { MtaService } from './classes.mta.js';
import { MTA } from './mta.classes.mta.js';
class DKIMVerifier {
public mtaRef: MtaService;
public mtaRef: MTA;
constructor(mtaRefArg: MtaService) {
constructor(mtaRefArg: MTA) {
this.mtaRef = mtaRefArg;
}

View File

@ -0,0 +1,10 @@
import type { MTA } from './mta.classes.mta.js';
import * as plugins from '../plugins.js';
export class DNSManager {
public mtaRef: MTA;
constructor(mtaRefArg: MTA) {
this.mtaRef = mtaRefArg;
}
}

View File

@ -0,0 +1,36 @@
export interface IAttachment {
filename: string;
content: Buffer;
contentType: string;
}
export interface IEmailOptions {
from: string;
to: string;
subject: string;
text: string;
attachments: IAttachment[];
mightBeSpam?: boolean;
}
export class Email {
from: string;
to: string;
subject: string;
text: string;
attachments: IAttachment[];
mightBeSpam: boolean;
constructor(options: IEmailOptions) {
this.from = options.from;
this.to = options.to;
this.subject = options.subject;
this.text = options.text;
this.attachments = options.attachments;
this.mightBeSpam = options.mightBeSpam || false;
}
public getFromDomain() {
return this.from.split('@')[1]
}
}

View File

@ -0,0 +1,173 @@
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 { MTA } from './mta.classes.mta.js';
export class EmailSendJob {
mtaRef: MTA;
private email: Email;
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
private mxRecord: string = null;
constructor(mtaRef: MTA, emailArg: Email) {
this.email = emailArg;
this.mtaRef = mtaRef;
}
async send(): Promise<void> {
const domain = this.email.to.split('@')[1];
const addresses = await this.resolveMx(domain);
addresses.sort((a, b) => a.priority - b.priority);
this.mxRecord = addresses[0].exchange;
console.log(`Using ${this.mxRecord} as mail server for domain ${domain}`);
this.socket = plugins.net.connect(25, this.mxRecord);
await this.processInitialResponse();
await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250');
try {
await this.sendCommand('STARTTLS\r\n', '220');
this.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false });
await this.processTLSUpgrade(this.email.from.split('@')[1]);
} catch (error) {
console.log('Error sending STARTTLS command:', error);
console.log('Continuing with unencrypted connection...');
}
await this.sendMessage();
}
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
return new Promise((resolve, reject) => {
plugins.dns.resolveMx(domain, (err, addresses) => {
if (err) {
console.error('Error resolving MX:', err);
reject(err);
} else {
resolve(addresses);
}
});
});
}
private processInitialResponse(): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.once('data', (data) => {
const response = data.toString();
if (!response.startsWith('220')) {
console.error('Unexpected initial server response:', response);
reject(new Error(`Unexpected initial server response: ${response}`));
} else {
console.log('Received initial server response:', response);
console.log('Connected to server, sending EHLO...');
resolve();
}
});
});
}
private processTLSUpgrade(domain: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.once('secureConnect', async () => {
console.log('TLS started successfully');
try {
await this.sendCommand(`EHLO ${domain}\r\n`, '250');
resolve();
} catch (err) {
console.error('Error sending EHLO after TLS upgrade:', err);
reject(err);
}
});
});
}
private sendCommand(command: string, expectedResponseCode?: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.write(command, (error) => {
if (error) {
reject(error);
return;
}
if (!expectedResponseCode) {
resolve();
return;
}
this.socket.once('data', (data) => {
const response = data.toString();
if (response.startsWith('221')) {
this.socket.destroy();
resolve();
}
if (!response.startsWith(expectedResponseCode)) {
reject(new Error(`Unexpected server response: ${response}`));
} else {
resolve();
}
});
});
});
}
private async sendMessage(): Promise<void> {
console.log('Preparing email message...');
const messageId = `<${plugins.uuid.v4()}@${this.email.from.split('@')[1]}>`;
// Create a boundary for the email parts
const boundary = '----=_NextPart_' + plugins.uuid.v4();
const headers = {
From: this.email.from,
To: this.email.to,
Subject: this.email.subject,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
};
// Construct the body of the message
let body = `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
// Then, the attachments
for (let 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\r\n`;
body += attachment.content.toString('base64') + '\r\n';
}
// End of email
body += `--${boundary}--\r\n`;
// Create an instance of DKIMSigner
const dkimSigner = new EmailSignJob(this.mtaRef, {
domain: this.email.getFromDomain(), // Replace with your domain
selector: `mta`, // Replace with your DKIM selector
headers: headers,
body: body,
});
// Construct the message with DKIM-Signature header
let message = `Message-ID: ${messageId}\r\nFrom: ${this.email.from}\r\nTo: ${this.email.to}\r\nSubject: ${this.email.subject}\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
message += body;
let signatureHeader = await dkimSigner.getSignatureHeader(message);
message = `${signatureHeader}${message}`;
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
plugins.smartfile.memory.toFsSync(message, plugins.path.join(paths.sentEmailsDir, `${Date.now()}.eml`));
// Adding necessary commands before sending the actual email message
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
await this.sendCommand(`RCPT TO:<${this.email.to}>\r\n`, '250');
await this.sendCommand(`DATA\r\n`, '354');
// Now send the message content
await this.sendCommand(message);
await this.sendCommand('\r\n.\r\n', '250');
await this.sendCommand('QUIT\r\n', '221');
console.log('Email message sent successfully!');
}
}

View File

@ -1,5 +1,5 @@
import * as plugins from '../plugins.js';
import type { MtaService } from './mta.classes.mta.js';
import type { MTA } from './mta.classes.mta.js';
interface Headers {
[key: string]: string;
@ -13,10 +13,10 @@ interface IEmailSignJobOptions {
}
export class EmailSignJob {
mtaRef: MtaService;
mtaRef: MTA;
jobOptions: IEmailSignJobOptions;
constructor(mtaRefArg: MtaService, options: IEmailSignJobOptions) {
constructor(mtaRefArg: MTA, options: IEmailSignJobOptions) {
this.mtaRef = mtaRefArg;
this.jobOptions = options;
}

66
ts/mta/mta.classes.mta.ts Normal file
View File

@ -0,0 +1,66 @@
import * as plugins from '../plugins.js';
import { Email } from './mta.classes.email.js';
import { EmailSendJob } from './mta.classes.emailsendjob.js';
import { DKIMCreator } from './mta.classes.dkimcreator.js';
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
import { SMTPServer } from './mta.classes.smtpserver.js';
import { DNSManager } from './mta.classes.dnsmanager.js';
export class MTA {
public server: SMTPServer;
public dkimCreator: DKIMCreator;
public dkimVerifier: DKIMVerifier;
public dnsManager: DNSManager;
constructor() {
this.dkimCreator = new DKIMCreator(this);
this.dkimVerifier = new DKIMVerifier(this);
this.dnsManager = new DNSManager(this);
}
public async start() {
// lets get the certificate
/**
* gets a certificate for a domain used by a service
* @param serviceNameArg
* @param domainNameArg
*/
const typedrouter = new plugins.typedrequest.TypedRouter();
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
typedrouter,
'https://cloudly.lossless.one:443'
);
const getCertificateForDomainOverHttps = async (domainNameArg: string) => {
const typedCertificateRequest =
typedsocketClient.createTypedRequest<any>('getSslCertificate');
const typedResponse = await typedCertificateRequest.fire({
authToken: '', // do proper auth here
requiredCertName: domainNameArg,
});
return typedResponse.certificate;
};
const certificate = await getCertificateForDomainOverHttps('mta.lossless.one');
await typedsocketClient.stop();
this.server = new SMTPServer(this, {
port: 25,
key: certificate.privateKey,
cert: certificate.publicKey,
});
await this.server.start();
}
public async stop() {
if (!this.server) {
console.error('Server is not running');
return;
}
await this.server.stop();
}
public async send(email: Email): Promise<void> {
await this.dkimCreator.handleDKIMKeysForEmail(email);
const sendJob = new EmailSendJob(this, email);
await sendJob.send();
}
}

View File

@ -0,0 +1,191 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './mta.classes.email.js';
import type { MTA } from './mta.classes.mta.js';
export interface ISmtpServerOptions {
port: number;
key: string;
cert: string;
}
export class SMTPServer {
public mtaRef: MTA;
private smtpServerOptions: ISmtpServerOptions;
private server: plugins.net.Server;
private emailBufferStringMap: Map<plugins.net.Socket, string>;
constructor(mtaRefArg: MTA, optionsArg: ISmtpServerOptions) {
console.log('SMTPServer instance is being created...');
this.mtaRef = mtaRefArg;
this.smtpServerOptions = optionsArg;
this.emailBufferStringMap = new Map();
this.server = plugins.net.createServer((socket) => {
console.log('New connection established...');
socket.write('220 mta.lossless.one ESMTP Postfix\r\n');
socket.on('data', (data) => {
this.processData(socket, data);
});
socket.on('end', () => {
console.log('Socket closed. Deleting related emailBuffer...');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
socket.on('error', () => {
console.error('Socket error occurred. Deleting related emailBuffer...');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
socket.on('close', () => {
console.log('Connection was closed by the client');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
});
}
private startTLS(socket: plugins.net.Socket) {
const secureContext = plugins.tls.createSecureContext({
key: this.smtpServerOptions.key,
cert: this.smtpServerOptions.cert,
});
const tlsSocket = new plugins.tls.TLSSocket(socket, {
secureContext: secureContext,
isServer: true,
});
tlsSocket.on('secure', () => {
console.log('Connection secured.');
this.emailBufferStringMap.set(tlsSocket, this.emailBufferStringMap.get(socket) || '');
this.emailBufferStringMap.delete(socket);
});
// Use the same handler for the 'data' event as for the unsecured socket.
tlsSocket.on('data', (data: Buffer) => {
this.processData(tlsSocket, Buffer.from(data));
});
tlsSocket.on('end', () => {
console.log('TLS socket closed. Deleting related emailBuffer...');
this.emailBufferStringMap.delete(tlsSocket);
});
tlsSocket.on('error', (err) => {
console.error('TLS socket error occurred. Deleting related emailBuffer...');
this.emailBufferStringMap.delete(tlsSocket);
});
}
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer) {
const dataString = data.toString();
console.log(`Received data:`);
console.log(`${dataString}`)
if (dataString.startsWith('EHLO')) {
socket.write('250-mta.lossless.one Hello\r\n250 STARTTLS\r\n');
} else if (dataString.startsWith('MAIL FROM')) {
socket.write('250 Ok\r\n');
} else if (dataString.startsWith('RCPT TO')) {
socket.write('250 Ok\r\n');
} else if (dataString.startsWith('STARTTLS')) {
socket.write('220 Ready to start TLS\r\n');
this.startTLS(socket);
} else if (dataString.startsWith('DATA')) {
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
let emailBuffer = this.emailBufferStringMap.get(socket);
if (!emailBuffer) {
this.emailBufferStringMap.set(socket, '');
}
} else if (dataString.startsWith('QUIT')) {
socket.write('221 Bye\r\n');
console.log('Received QUIT command, closing the socket...');
socket.destroy();
this.parseEmail(socket);
} else {
let emailBuffer = this.emailBufferStringMap.get(socket);
if (typeof emailBuffer === 'string') {
emailBuffer += dataString;
this.emailBufferStringMap.set(socket, emailBuffer);
}
socket.write('250 Ok\r\n');
}
if (dataString.endsWith('\r\n.\r\n') ) { // End of data
console.log('Received end of data.');
}
}
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket) {
let emailData = this.emailBufferStringMap.get(socket);
// lets strip the end sequence
emailData = emailData?.replace(/\r\n\.\r\n$/, '');
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
plugins.smartfile.memory.toFsSync(emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`));
if (!emailData) {
console.error('No email data found for socket.');
return;
}
let mightBeSpam = false;
// Verifying the email with DKIM
try {
const isVerified = await this.mtaRef.dkimVerifier.verify(emailData);
mightBeSpam = !isVerified;
} catch (error) {
console.error('Failed to verify DKIM signature:', error);
mightBeSpam = true;
}
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
console.log(parsedEmail)
const email = new Email({
from: parsedEmail.from?.value[0].address || '',
to:
parsedEmail.to instanceof Array
? parsedEmail.to[0].value[0].address
: parsedEmail.to?.value[0].address,
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('mail received!');
console.log(email);
this.emailBufferStringMap.delete(socket);
}
public start() {
this.server.listen(this.smtpServerOptions.port, () => {
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
});
}
public stop() {
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');
});
}
}

View File

@ -1,29 +1,12 @@
import * as plugins from './plugins.js';
// Base directories
export const baseDir = process.cwd();
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);
export const dataDir = plugins.path.join(baseDir, 'data');
// MTA directories
export const keysDir = plugins.path.join(dataDir, 'keys');
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
// Create directories if they don't exist
export function ensureDirectories() {
// Ensure data directories
plugins.smartfile.fs.ensureDirSync(dataDir);
plugins.smartfile.fs.ensureDirSync(keysDir);
plugins.smartfile.fs.ensureDirSync(dnsRecordsDir);
plugins.smartfile.fs.ensureDirSync(sentEmailsDir);
plugins.smartfile.fs.ensureDirSync(receivedEmailsDir);
plugins.smartfile.fs.ensureDirSync(failedEmailsDir);
plugins.smartfile.fs.ensureDirSync(logsDir);
}
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);

View File

@ -1,44 +0,0 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { PlatformServiceDb } from './classes.platformservicedb.js'
import { EmailService } from './email/classes.emailservice.js';
import { SmsService } from './sms/classes.smsservice.js';
import { LetterService } from './letter/classes.letterservice.js';
import { MtaService } from './mta/classes.mta.js';
export class SzPlatformService {
public projectinfo: plugins.projectinfo.ProjectInfo;
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
public platformserviceDb: PlatformServiceDb;
public typedserver: plugins.typedserver.TypedServer;
public typedrouter = new plugins.typedrequest.TypedRouter();
// SubServices
public emailService: EmailService;
public letterService: LetterService;
public mtaService: MtaService;
public smsService: SmsService;
public async start() {
this.platformserviceDb = new PlatformServiceDb(this);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
// lets start the sub services
this.emailService = new EmailService(this);
this.letterService = new LetterService(this, {
letterxpressUser: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_USER'),
letterxpressToken: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_TOKEN')
});
this.mtaService = new MtaService(this);
this.smsService = new SmsService(this, {
apiGatewayApiToken: await this.serviceQenv.getEnvVarOnDemand('SMS_API_TOKEN'),
});
// lets start the server finally
this.typedserver = new plugins.typedserver.TypedServer({
cors: true,
});
await this.typedserver.start();
}
}

View File

@ -2,7 +2,6 @@
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as http from 'http';
import * as net from 'net';
import * as path from 'path';
import * as tls from 'tls';
@ -12,7 +11,6 @@ export {
dns,
fs,
crypto,
http,
net,
path,
tls,
@ -45,20 +43,11 @@ import * as smartfile from '@push.rocks/smartfile';
import * as smartlog from '@push.rocks/smartlog';
import * as smartmail from '@push.rocks/smartmail';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrule from '@push.rocks/smartrule';
import * as smartrx from '@push.rocks/smartrx';
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx };
// apiclient.xyz scope
import * as letterxpress from '@apiclient.xyz/letterxpress';
export {
letterxpress,
}
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartpromise, smartrequest, smartrx };
// tsclass scope
import * as tsclass from '@tsclass/tsclass';

View File

@ -1 +1 @@
export * from './classes.smsservice.js';
export * from './smsservice.js';

View File

@ -1,10 +1,10 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
import type { SzPlatformService } from '../platformservice.js';
import type { SzPlatformService } from '../classes.platformservice.js';
export interface ISmsConstructorOptions {
apiGatewayApiToken: string;
apiToken: string;
}
export class SmsService {
@ -63,7 +63,7 @@ export class SmsService {
method: 'POST',
requestBody: JSON.stringify(payload),
headers: {
Authorization: `Basic ${Buffer.from(`${this.options.apiGatewayApiToken}:`).toString('base64')}`,
Authorization: `Basic ${Buffer.from(`${this.options.apiToken}:`).toString('base64')}`,
'Content-Type': 'application/json',
},
});

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",