feat(mta): Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
This commit is contained in:
parent
c1311f493f
commit
4dc095e662
39
changelog.md
Normal file
39
changelog.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-03-15 - 1.1.0 - feat(mta)
|
||||||
|
Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
|
||||||
|
|
||||||
|
- Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering
|
||||||
|
- Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods
|
||||||
|
- Update Email class to support multiple recipients, CC, BCC with input sanitization and validation
|
||||||
|
- Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob
|
||||||
|
- Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics
|
||||||
|
- Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration
|
||||||
|
|
||||||
|
## 2024-05-11 - 1.0.10 to 1.0.8 - core
|
||||||
|
Applied core fixes across several versions on this day.
|
||||||
|
|
||||||
|
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
||||||
|
|
||||||
|
## 2024-04-01 - 1.0.7 - core
|
||||||
|
Applied a core fix.
|
||||||
|
|
||||||
|
- Fixed core functionality for version 1.0.7
|
||||||
|
|
||||||
|
## 2024-03-19 - 1.0.6 - core
|
||||||
|
Applied a core fix.
|
||||||
|
|
||||||
|
- Fixed core functionality for version 1.0.6
|
||||||
|
|
||||||
|
## 2024-02-16 - 1.0.5 to 1.0.2 - core
|
||||||
|
Applied multiple core fixes in a contiguous range of versions.
|
||||||
|
|
||||||
|
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
|
||||||
|
|
||||||
|
## 2024-02-15 - 1.0.1 - core
|
||||||
|
Applied a core fix.
|
||||||
|
|
||||||
|
- Fixed core functionality for version 1.0.1
|
||||||
|
|
||||||
|
–––––––––––––––––––––––
|
||||||
|
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain “1.0.x” commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
13229
pnpm-lock.yaml
generated
13229
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* autocreated commitinfo by @pushrocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/platformservice',
|
name: '@serve.zone/platformservice',
|
||||||
version: '1.0.11',
|
version: '1.1.0',
|
||||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,846 @@
|
|||||||
import * as plugins from '../plugins.js';
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,10 +1,558 @@
|
|||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
import * as plugins from '../plugins.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 {
|
export class DNSManager {
|
||||||
public mtaRef: MtaService;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,35 +2,218 @@ export interface IAttachment {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: Buffer;
|
content: Buffer;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string; // Optional content ID for inline attachments
|
||||||
|
encoding?: string; // Optional encoding specification
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmailOptions {
|
export interface IEmailOptions {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string | string[]; // Support multiple recipients
|
||||||
|
cc?: string | string[]; // Optional CC recipients
|
||||||
|
bcc?: string | string[]; // Optional BCC recipients
|
||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
attachments: IAttachment[];
|
html?: string; // Optional HTML version
|
||||||
|
attachments?: IAttachment[];
|
||||||
|
headers?: Record<string, string>; // Optional additional headers
|
||||||
mightBeSpam?: boolean;
|
mightBeSpam?: boolean;
|
||||||
|
priority?: 'high' | 'normal' | 'low'; // Optional email priority
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Email {
|
export class Email {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string[];
|
||||||
|
cc: string[];
|
||||||
|
bcc: string[];
|
||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
html?: string;
|
||||||
attachments: IAttachment[];
|
attachments: IAttachment[];
|
||||||
|
headers: Record<string, string>;
|
||||||
mightBeSpam: boolean;
|
mightBeSpam: boolean;
|
||||||
|
priority: 'high' | 'normal' | 'low';
|
||||||
|
|
||||||
constructor(options: IEmailOptions) {
|
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.from = options.from;
|
||||||
this.to = options.to;
|
|
||||||
this.subject = options.subject;
|
// Handle to addresses (single or multiple)
|
||||||
this.text = options.text;
|
this.to = this.parseRecipients(options.to);
|
||||||
this.attachments = options.attachments;
|
|
||||||
this.mightBeSpam = options.mightBeSpam || false;
|
// 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() {
|
// Set subject with sanitization
|
||||||
return this.from.split('@')[1]
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,45 +4,553 @@ import { Email } from './mta.classes.email.js';
|
|||||||
import { EmailSignJob } from './mta.classes.emailsignjob.js';
|
import { EmailSignJob } from './mta.classes.emailsignjob.js';
|
||||||
import type { MtaService } from './mta.classes.mta.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 {
|
export class EmailSendJob {
|
||||||
mtaRef: MtaService;
|
mtaRef: MtaService;
|
||||||
private email: Email;
|
private email: Email;
|
||||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
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.email = emailArg;
|
||||||
this.mtaRef = mtaRef;
|
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);
|
const addresses = await this.resolveMx(domain);
|
||||||
|
|
||||||
|
// Sort by priority (lowest number = highest priority)
|
||||||
addresses.sort((a, b) => a.priority - b.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);
|
if (this.mxServers.length === 0) {
|
||||||
await this.processInitialResponse();
|
throw new Error(`No MX records found for domain: ${domain}`);
|
||||||
await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250');
|
}
|
||||||
|
} 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 {
|
try {
|
||||||
await this.sendCommand('STARTTLS\r\n', '220');
|
await this.sendCommand('STARTTLS\r\n', '220');
|
||||||
this.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false });
|
this.upgradeToTLS(mxServer, fromDomain);
|
||||||
await this.processTLSUpgrade(this.email.from.split('@')[1]);
|
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
|
||||||
|
// resolve will be called from there if successful
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Error sending STARTTLS command:', error);
|
this.log(`STARTTLS failed or not supported: ${error.message}`);
|
||||||
console.log('Continuing with unencrypted connection...');
|
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[]> {
|
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error resolving MX:', err);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
resolve(addresses);
|
resolve(addresses);
|
||||||
@ -51,123 +559,65 @@ export class EmailSendJob {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private processInitialResponse(): Promise<void> {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* Add a log entry
|
||||||
this.socket.once('data', (data) => {
|
*/
|
||||||
const response = data.toString();
|
private log(message: string): void {
|
||||||
if (!response.startsWith('220')) {
|
const timestamp = new Date().toISOString();
|
||||||
console.error('Unexpected initial server response:', response);
|
const logEntry = `[${timestamp}] ${message}`;
|
||||||
reject(new Error(`Unexpected initial server response: ${response}`));
|
this.deliveryInfo.logs.push(logEntry);
|
||||||
} else {
|
|
||||||
console.log('Received initial server response:', response);
|
if (this.options.debugMode) {
|
||||||
console.log('Connected to server, sending EHLO...');
|
console.log(`EmailSendJob: ${logEntry}`);
|
||||||
resolve();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private processTLSUpgrade(domain: string): Promise<void> {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* Save a successful email for record keeping
|
||||||
this.socket.once('secureConnect', async () => {
|
*/
|
||||||
console.log('TLS started successfully');
|
private async saveSuccess(): Promise<void> {
|
||||||
try {
|
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.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));
|
||||||
|
|
||||||
|
// Save delivery info
|
||||||
// Adding necessary commands before sending the actual email message
|
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
|
||||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
plugins.smartfile.memory.toFsSync(
|
||||||
await this.sendCommand(`RCPT TO:<${this.email.to}>\r\n`, '250');
|
JSON.stringify(this.deliveryInfo, null, 2),
|
||||||
await this.sendCommand(`DATA\r\n`, '354');
|
plugins.path.join(paths.sentEmailsDir, infoFileName)
|
||||||
|
);
|
||||||
// Now send the message content
|
} catch (error) {
|
||||||
await this.sendCommand(message);
|
console.error('Error saving successful email:', error);
|
||||||
await this.sendCommand('\r\n.\r\n', '250');
|
}
|
||||||
|
}
|
||||||
await this.sendCommand('QUIT\r\n', '221');
|
|
||||||
console.log('Email message sent successfully!');
|
/**
|
||||||
|
* Save a failed email for potential retry
|
||||||
|
*/
|
||||||
|
private async saveFailed(): Promise<void> {
|
||||||
|
try {
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
||||||
|
const emailContent = await this.createEmailMessage();
|
||||||
|
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
|
||||||
|
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
|
||||||
|
|
||||||
|
// Save delivery info
|
||||||
|
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
|
||||||
|
plugins.smartfile.memory.toFsSync(
|
||||||
|
JSON.stringify(this.deliveryInfo, null, 2),
|
||||||
|
plugins.path.join(paths.failedEmailsDir, infoFileName)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving failed email:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple delay function
|
||||||
|
*/
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,69 +1,945 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as paths from '../paths.js';
|
||||||
|
|
||||||
import { Email } from './mta.classes.email.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 { DKIMCreator } from './mta.classes.dkimcreator.js';
|
||||||
import { DKIMVerifier } from './mta.classes.dkimverifier.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 { DNSManager } from './mta.classes.dnsmanager.js';
|
||||||
|
import { ApiManager } from './mta.classes.apimanager.js';
|
||||||
import type { SzPlatformService } from '../classes.platformservice.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 {
|
export class MtaService {
|
||||||
|
/** Reference to the platform service */
|
||||||
public platformServiceRef: SzPlatformService;
|
public platformServiceRef: SzPlatformService;
|
||||||
|
|
||||||
|
/** SMTP server instance */
|
||||||
public server: SMTPServer;
|
public server: SMTPServer;
|
||||||
|
|
||||||
|
/** DKIM creator for signing outgoing emails */
|
||||||
public dkimCreator: DKIMCreator;
|
public dkimCreator: DKIMCreator;
|
||||||
|
|
||||||
|
/** DKIM verifier for validating incoming emails */
|
||||||
public dkimVerifier: DKIMVerifier;
|
public dkimVerifier: DKIMVerifier;
|
||||||
|
|
||||||
|
/** DNS manager for handling DNS records */
|
||||||
public dnsManager: DNSManager;
|
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;
|
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.dkimCreator = new DKIMCreator(this);
|
||||||
this.dkimVerifier = new DKIMVerifier(this);
|
this.dkimVerifier = new DKIMVerifier(this);
|
||||||
this.dnsManager = new DNSManager(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
|
* Get default configuration
|
||||||
* @param serviceNameArg
|
|
||||||
* @param domainNameArg
|
|
||||||
*/
|
*/
|
||||||
|
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 typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||||
typedrouter,
|
typedrouter,
|
||||||
'https://cloudly.lossless.one:443'
|
'https://cloudly.lossless.one:443'
|
||||||
);
|
);
|
||||||
const getCertificateForDomainOverHttps = async (domainNameArg: string) => {
|
|
||||||
const typedCertificateRequest =
|
try {
|
||||||
typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
// Request certificate
|
||||||
|
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
||||||
const typedResponse = await typedCertificateRequest.fire({
|
const typedResponse = await typedCertificateRequest.fire({
|
||||||
authToken: '', // do proper auth here
|
authToken,
|
||||||
requiredCertName: domainNameArg,
|
requiredCertName: domain,
|
||||||
});
|
});
|
||||||
return typedResponse.certificate;
|
|
||||||
};
|
if (!typedResponse || !typedResponse.certificate) {
|
||||||
const certificate = await getCertificateForDomainOverHttps('mta.lossless.one');
|
throw new Error('Invalid response from certificate service');
|
||||||
await typedsocketClient.stop();
|
|
||||||
this.server = new SMTPServer(this, {
|
|
||||||
port: 25,
|
|
||||||
key: certificate.privateKey,
|
|
||||||
cert: certificate.publicKey,
|
|
||||||
});
|
|
||||||
await this.server.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
// Extract certificate information
|
||||||
if (!this.server) {
|
const cert = typedResponse.certificate;
|
||||||
console.error('Server is not running');
|
|
||||||
|
// 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;
|
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);
|
* Renew the certificate
|
||||||
const sendJob = new EmailSendJob(this, email);
|
*/
|
||||||
await sendJob.send();
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -7,51 +7,392 @@ export interface ISmtpServerOptions {
|
|||||||
port: number;
|
port: number;
|
||||||
key: string;
|
key: string;
|
||||||
cert: 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 {
|
export class SMTPServer {
|
||||||
public mtaRef: MtaService;
|
public mtaRef: MtaService;
|
||||||
private smtpServerOptions: ISmtpServerOptions;
|
private smtpServerOptions: ISmtpServerOptions;
|
||||||
private server: plugins.net.Server;
|
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) {
|
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
||||||
console.log('SMTPServer instance is being created...');
|
console.log('SMTPServer instance is being created...');
|
||||||
|
|
||||||
this.mtaRef = mtaRefArg;
|
this.mtaRef = mtaRefArg;
|
||||||
this.smtpServerOptions = optionsArg;
|
this.smtpServerOptions = optionsArg;
|
||||||
this.emailBufferStringMap = new Map();
|
this.sessions = new Map();
|
||||||
|
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
||||||
|
|
||||||
this.server = plugins.net.createServer((socket) => {
|
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) => {
|
socket.on('data', (data) => {
|
||||||
this.processData(socket, data);
|
this.processData(socket, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('end', () => {
|
socket.on('end', () => {
|
||||||
console.log('Socket closed. Deleting related emailBuffer...');
|
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
socket.destroy();
|
const session = this.sessions.get(socket);
|
||||||
this.emailBufferStringMap.delete(socket);
|
if (session) {
|
||||||
|
session.connectionEnded = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', () => {
|
socket.on('error', (err) => {
|
||||||
console.error('Socket error occurred. Deleting related emailBuffer...');
|
console.error(`Socket error: ${err.message}`);
|
||||||
|
this.sessions.delete(socket);
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
this.emailBufferStringMap.delete(socket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
console.log('Connection was closed by the client');
|
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
socket.destroy();
|
this.sessions.delete(socket);
|
||||||
this.emailBufferStringMap.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({
|
const secureContext = plugins.tls.createSecureContext({
|
||||||
key: this.smtpServerOptions.key,
|
key: this.smtpServerOptions.key,
|
||||||
cert: this.smtpServerOptions.cert,
|
cert: this.smtpServerOptions.cert,
|
||||||
@ -60,130 +401,74 @@ export class SMTPServer {
|
|||||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
||||||
secureContext: secureContext,
|
secureContext: secureContext,
|
||||||
isServer: true,
|
isServer: true,
|
||||||
|
server: this.server
|
||||||
});
|
});
|
||||||
|
|
||||||
tlsSocket.on('secure', () => {
|
const originalSession = this.sessions.get(socket);
|
||||||
console.log('Connection secured.');
|
if (!originalSession) {
|
||||||
this.emailBufferStringMap.set(tlsSocket, this.emailBufferStringMap.get(socket) || '');
|
console.error('No session found when upgrading to TLS');
|
||||||
this.emailBufferStringMap.delete(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the same handler for the 'data' event as for the unsecured socket.
|
|
||||||
tlsSocket.on('data', (data: Buffer) => {
|
|
||||||
this.processData(tlsSocket, Buffer.from(data));
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('end', () => {
|
|
||||||
console.log('TLS socket closed. Deleting related emailBuffer...');
|
|
||||||
this.emailBufferStringMap.delete(tlsSocket);
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (err) => {
|
|
||||||
console.error('TLS socket error occurred. Deleting related emailBuffer...');
|
|
||||||
this.emailBufferStringMap.delete(tlsSocket);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer) {
|
|
||||||
const dataString = data.toString();
|
|
||||||
console.log(`Received data:`);
|
|
||||||
console.log(`${dataString}`)
|
|
||||||
|
|
||||||
if (dataString.startsWith('EHLO')) {
|
|
||||||
socket.write('250-mta.lossless.one Hello\r\n250 STARTTLS\r\n');
|
|
||||||
} else if (dataString.startsWith('MAIL FROM')) {
|
|
||||||
socket.write('250 Ok\r\n');
|
|
||||||
} else if (dataString.startsWith('RCPT TO')) {
|
|
||||||
socket.write('250 Ok\r\n');
|
|
||||||
} else if (dataString.startsWith('STARTTLS')) {
|
|
||||||
socket.write('220 Ready to start TLS\r\n');
|
|
||||||
this.startTLS(socket);
|
|
||||||
} else if (dataString.startsWith('DATA')) {
|
|
||||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
|
||||||
let emailBuffer = this.emailBufferStringMap.get(socket);
|
|
||||||
if (!emailBuffer) {
|
|
||||||
this.emailBufferStringMap.set(socket, '');
|
|
||||||
}
|
|
||||||
} else if (dataString.startsWith('QUIT')) {
|
|
||||||
socket.write('221 Bye\r\n');
|
|
||||||
console.log('Received QUIT command, closing the socket...');
|
|
||||||
socket.destroy();
|
|
||||||
this.parseEmail(socket);
|
|
||||||
} else {
|
|
||||||
let emailBuffer = this.emailBufferStringMap.get(socket);
|
|
||||||
if (typeof emailBuffer === 'string') {
|
|
||||||
emailBuffer += dataString;
|
|
||||||
this.emailBufferStringMap.set(socket, emailBuffer);
|
|
||||||
}
|
|
||||||
socket.write('250 Ok\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataString.endsWith('\r\n.\r\n') ) { // End of data
|
|
||||||
console.log('Received end of data.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket) {
|
|
||||||
let emailData = this.emailBufferStringMap.get(socket);
|
|
||||||
// lets strip the end sequence
|
|
||||||
emailData = emailData?.replace(/\r\n\.\r\n$/, '');
|
|
||||||
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
|
||||||
plugins.smartfile.memory.toFsSync(emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`));
|
|
||||||
|
|
||||||
|
|
||||||
if (!emailData) {
|
|
||||||
console.error('No email data found for socket.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mightBeSpam = false;
|
// Transfer the session data to the new TLS socket
|
||||||
|
this.sessions.set(tlsSocket, {
|
||||||
// Verifying the email with DKIM
|
...originalSession,
|
||||||
try {
|
useTLS: true,
|
||||||
const isVerified = await this.mtaRef.dkimVerifier.verify(emailData);
|
state: SmtpState.GREETING // Reset state to require a new EHLO
|
||||||
mightBeSpam = !isVerified;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to verify DKIM signature:', error);
|
|
||||||
mightBeSpam = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
|
||||||
console.log(parsedEmail)
|
|
||||||
const email = new Email({
|
|
||||||
from: parsedEmail.from?.value[0].address || '',
|
|
||||||
to:
|
|
||||||
parsedEmail.to instanceof Array
|
|
||||||
? parsedEmail.to[0].value[0].address
|
|
||||||
: parsedEmail.to?.value[0].address,
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
text: parsedEmail.html || parsedEmail.text,
|
|
||||||
attachments:
|
|
||||||
parsedEmail.attachments?.map((attachment) => ({
|
|
||||||
filename: attachment.filename || '',
|
|
||||||
content: attachment.content,
|
|
||||||
contentType: attachment.contentType,
|
|
||||||
})) || [],
|
|
||||||
mightBeSpam: mightBeSpam,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('mail received!');
|
this.sessions.delete(socket);
|
||||||
console.log(email);
|
|
||||||
|
|
||||||
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, () => {
|
this.server.listen(this.smtpServerOptions.port, () => {
|
||||||
console.log(`SMTP Server is now running on port ${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) => {
|
this.server.getConnections((err, count) => {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
console.log('Number of active connections: ', count);
|
console.log('Number of active connections: ', count);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.close(() => {
|
this.server.close(() => {
|
||||||
console.log('SMTP Server is now stopped');
|
console.log('SMTP Server is now stopped');
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user