feat(mta): Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes

This commit is contained in:
Philipp Kunz 2025-03-15 13:45:29 +00:00
parent c1311f493f
commit 4dc095e662
9 changed files with 11759 additions and 5462 deletions

39
changelog.md Normal file
View File

@ -0,0 +1,39 @@
# Changelog
## 2025-03-15 - 1.1.0 - feat(mta)
Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
- Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering
- Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods
- Update Email class to support multiple recipients, CC, BCC with input sanitization and validation
- Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob
- Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics
- Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration
## 2024-05-11 - 1.0.10 to 1.0.8 - core
Applied core fixes across several versions on this day.
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
## 2024-04-01 - 1.0.7 - core
Applied a core fix.
- Fixed core functionality for version 1.0.7
## 2024-03-19 - 1.0.6 - core
Applied a core fix.
- Fixed core functionality for version 1.0.6
## 2024-02-16 - 1.0.5 to 1.0.2 - core
Applied multiple core fixes in a contiguous range of versions.
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
## 2024-02-15 - 1.0.1 - core
Applied a core fix.
- Fixed core functionality for version 1.0.1
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain “1.0.x” commits) have been omitted from individual entries and are implicitly included in the version ranges above.

13229
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -2,35 +2,218 @@ 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;
to: string | string[]; // Support multiple recipients
cc?: string | string[]; // Optional CC recipients
bcc?: string | string[]; // Optional BCC recipients
subject: string;
text: string;
attachments: IAttachment[];
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;
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;
this.to = options.to;
this.subject = options.subject;
this.text = options.text;
this.attachments = options.attachments;
this.mightBeSpam = options.mightBeSpam || false;
// 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');
}
public getFromDomain() {
return this.from.split('@')[1]
// 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

@ -4,45 +4,553 @@ import { Email } from './mta.classes.email.js';
import { EmailSignJob } from './mta.classes.emailsignjob.js';
import type { MtaService } from './mta.classes.mta.js';
// Configuration options for email sending
export interface IEmailSendOptions {
maxRetries?: number;
retryDelay?: number; // in milliseconds
connectionTimeout?: number; // in milliseconds
tlsOptions?: plugins.tls.ConnectionOptions;
debugMode?: boolean;
}
// Email delivery status
export enum DeliveryStatus {
PENDING = 'pending',
SENDING = 'sending',
DELIVERED = 'delivered',
FAILED = 'failed',
DEFERRED = 'deferred' // Temporary failure, will retry
}
// Detailed information about delivery attempts
export interface DeliveryInfo {
status: DeliveryStatus;
attempts: number;
error?: Error;
lastAttempt?: Date;
nextAttempt?: Date;
mxServer?: string;
deliveryTime?: Date;
logs: string[];
}
export class EmailSendJob {
mtaRef: MtaService;
private email: Email;
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
private mxRecord: string = null;
private mxServers: string[] = [];
private currentMxIndex = 0;
private options: IEmailSendOptions;
public deliveryInfo: DeliveryInfo;
constructor(mtaRef: MtaService, emailArg: Email) {
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: []
};
}
async send(): Promise<void> {
const domain = this.email.to.split('@')[1];
/**
* 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.mxRecord = addresses[0].exchange;
console.log(`Using ${this.mxRecord} as mail server for domain ${domain}`);
this.mxServers = addresses.map(mx => mx.exchange);
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
this.socket = plugins.net.connect(25, this.mxRecord);
await this.processInitialResponse();
await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250');
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.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false });
await this.processTLSUpgrade(this.email.from.split('@')[1]);
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) {
console.log('Error sending STARTTLS command:', error);
console.log('Continuing with unencrypted connection...');
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);
}
});
});
}
await this.sendMessage();
/**
* 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) {
console.error('Error resolving MX:', err);
reject(err);
} else {
resolve(addresses);
@ -51,123 +559,65 @@ export class EmailSendJob {
});
}
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();
/**
* 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}`);
}
});
});
}
private processTLSUpgrade(domain: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.once('secureConnect', async () => {
console.log('TLS started successfully');
/**
* Save a successful email for record keeping
*/
private async saveSuccess(): Promise<void> {
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`));
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));
// 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!');
// 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,69 +1,945 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Email } from './mta.classes.email.js';
import { EmailSendJob } from './mta.classes.emailsendjob.js';
import { EmailSendJob, DeliveryStatus } from './mta.classes.emailsendjob.js';
import { DKIMCreator } from './mta.classes.dkimcreator.js';
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
import { SMTPServer } from './mta.classes.smtpserver.js';
import { SMTPServer, type ISmtpServerOptions } from './mta.classes.smtpserver.js';
import { DNSManager } from './mta.classes.dnsmanager.js';
import { ApiManager } from './mta.classes.apimanager.js';
import type { SzPlatformService } from '../classes.platformservice.js';
/**
* Configuration options for the MTA service
*/
export interface IMtaConfig {
/** SMTP server options */
smtp?: {
/** Whether to enable the SMTP server */
enabled?: boolean;
/** Port to listen on (default: 25) */
port?: number;
/** SMTP server hostname */
hostname?: string;
/** Maximum allowed email size in bytes */
maxSize?: number;
};
/** SSL/TLS configuration */
tls?: {
/** Domain for certificate */
domain?: string;
/** Whether to auto-renew certificates */
autoRenew?: boolean;
/** Custom key/cert paths (if not using auto-provision) */
keyPath?: string;
certPath?: string;
};
/** Outbound email sending configuration */
outbound?: {
/** Maximum concurrent sending jobs */
concurrency?: number;
/** Retry configuration */
retries?: {
/** Maximum number of retries per message */
max?: number;
/** Initial delay between retries (milliseconds) */
delay?: number;
/** Whether to use exponential backoff for retries */
useBackoff?: boolean;
};
/** Rate limiting configuration */
rateLimit?: {
/** Maximum emails per period */
maxPerPeriod?: number;
/** Time period in milliseconds */
periodMs?: number;
/** Whether to apply per domain (vs globally) */
perDomain?: boolean;
};
};
/** Security settings */
security?: {
/** Whether to use DKIM signing */
useDkim?: boolean;
/** Whether to verify inbound DKIM signatures */
verifyDkim?: boolean;
/** Whether to verify SPF on inbound */
verifySpf?: boolean;
/** Whether to use TLS for outbound when available */
useTls?: boolean;
/** Whether to require valid certificates */
requireValidCerts?: boolean;
};
/** Domains configuration */
domains?: {
/** List of domains that this MTA will handle as local */
local?: string[];
/** Whether to auto-create DNS records */
autoCreateDnsRecords?: boolean;
/** DKIM selector to use (default: "mta") */
dkimSelector?: string;
};
}
/**
* Email queue entry
*/
interface QueueEntry {
id: string;
email: Email;
addedAt: Date;
processing: boolean;
attempts: number;
lastAttempt?: Date;
nextAttempt?: Date;
error?: Error;
status: DeliveryStatus;
}
/**
* Certificate information
*/
interface Certificate {
privateKey: string;
publicKey: string;
expiresAt: Date;
}
/**
* Stats for MTA monitoring
*/
interface MtaStats {
startTime: Date;
emailsReceived: number;
emailsSent: number;
emailsFailed: number;
activeConnections: number;
queueSize: number;
certificateInfo?: {
domain: string;
expiresAt: Date;
daysUntilExpiry: number;
};
}
/**
* Main MTA Service class that coordinates all email functionality
*/
export class MtaService {
/** Reference to the platform service */
public platformServiceRef: SzPlatformService;
/** SMTP server instance */
public server: SMTPServer;
/** DKIM creator for signing outgoing emails */
public dkimCreator: DKIMCreator;
/** DKIM verifier for validating incoming emails */
public dkimVerifier: DKIMVerifier;
/** DNS manager for handling DNS records */
public dnsManager: DNSManager;
constructor(platformServiceRefArg: SzPlatformService) {
/** 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();
}
public async start() {
// lets get the certificate
/**
* gets a certificate for a domain used by a service
* @param serviceNameArg
* @param domainNameArg
* 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'
);
const getCertificateForDomainOverHttps = async (domainNameArg: string) => {
const typedCertificateRequest =
typedsocketClient.createTypedRequest<any>('getSslCertificate');
try {
// Request certificate
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
const typedResponse = await typedCertificateRequest.fire({
authToken: '', // do proper auth here
requiredCertName: domainNameArg,
authToken,
requiredCertName: domain,
});
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();
if (!typedResponse || !typedResponse.certificate) {
throw new Error('Invalid response from certificate service');
}
public async stop() {
if (!this.server) {
console.error('Server is not running');
// 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;
}
await this.server.stop();
// 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);
}
public async send(email: Email): Promise<void> {
await this.dkimCreator.handleDKIMKeysForEmail(email);
const sendJob = new EmailSendJob(this, email);
await sendJob.send();
/**
* 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

@ -7,51 +7,392 @@ 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 emailBufferStringMap: Map<plugins.net.Socket, string>;
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.emailBufferStringMap = new Map();
this.sessions = new Map();
this.hostname = optionsArg.hostname || 'mta.lossless.one';
this.server = plugins.net.createServer((socket) => {
console.log('New connection established...');
this.handleNewConnection(socket);
});
}
socket.write('220 mta.lossless.one ESMTP Postfix\r\n');
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('Socket closed. Deleting related emailBuffer...');
socket.destroy();
this.emailBufferStringMap.delete(socket);
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
const session = this.sessions.get(socket);
if (session) {
session.connectionEnded = true;
}
});
socket.on('error', () => {
console.error('Socket error occurred. Deleting related emailBuffer...');
socket.on('error', (err) => {
console.error(`Socket error: ${err.message}`);
this.sessions.delete(socket);
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
socket.on('close', () => {
console.log('Connection was closed by the client');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
this.sessions.delete(socket);
});
}
private startTLS(socket: plugins.net.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,
@ -60,130 +401,74 @@ export class SMTPServer {
const tlsSocket = new plugins.tls.TLSSocket(socket, {
secureContext: secureContext,
isServer: true,
server: this.server
});
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.');
const originalSession = this.sessions.get(socket);
if (!originalSession) {
console.error('No session found when upgrading to TLS');
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,
// 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
});
console.log('mail received!');
console.log(email);
this.sessions.delete(socket);
this.emailBufferStringMap.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();
}
}
public start() {
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() {
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');
});