431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
|
import * as plugins from '../plugins.js';
|
||
|
|
||
|
/**
|
||
|
* Domain group configuration for applying consistent rules across related domains
|
||
|
*/
|
||
|
export interface IDomainGroup {
|
||
|
/** Unique identifier for the domain group */
|
||
|
id: string;
|
||
|
/** Human-readable name for the domain group */
|
||
|
name: string;
|
||
|
/** List of domains in this group */
|
||
|
domains: string[];
|
||
|
/** Priority for this domain group (higher takes precedence) */
|
||
|
priority?: number;
|
||
|
/** Description of this domain group */
|
||
|
description?: string;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Domain pattern with wildcard support for matching domains
|
||
|
*/
|
||
|
export interface IDomainPattern {
|
||
|
/** The domain pattern, e.g. "example.com" or "*.example.com" */
|
||
|
pattern: string;
|
||
|
/** Whether this is an exact match or wildcard pattern */
|
||
|
isWildcard: boolean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Email routing rule for determining how to handle emails for specific domains
|
||
|
*/
|
||
|
export interface IEmailRoutingRule {
|
||
|
/** Unique identifier for this rule */
|
||
|
id: string;
|
||
|
/** Human-readable name for this rule */
|
||
|
name: string;
|
||
|
/** Source domain patterns to match (from address) */
|
||
|
sourceDomains?: IDomainPattern[];
|
||
|
/** Destination domain patterns to match (to address) */
|
||
|
destinationDomains?: IDomainPattern[];
|
||
|
/** Domain groups this rule applies to */
|
||
|
domainGroups?: string[];
|
||
|
/** Priority of this rule (higher takes precedence) */
|
||
|
priority: number;
|
||
|
/** Action to take when rule matches */
|
||
|
action: 'route' | 'block' | 'tag' | 'filter';
|
||
|
/** Target server for routing */
|
||
|
targetServer?: string;
|
||
|
/** Target port for routing */
|
||
|
targetPort?: number;
|
||
|
/** Whether to use TLS when routing */
|
||
|
useTls?: boolean;
|
||
|
/** Authentication details for routing */
|
||
|
auth?: {
|
||
|
/** Username for authentication */
|
||
|
username?: string;
|
||
|
/** Password for authentication */
|
||
|
password?: string;
|
||
|
/** Authentication type */
|
||
|
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
||
|
};
|
||
|
/** Headers to add or modify when rule matches */
|
||
|
headers?: {
|
||
|
/** Header name */
|
||
|
name: string;
|
||
|
/** Header value */
|
||
|
value: string;
|
||
|
/** Whether to append to existing header or replace */
|
||
|
append?: boolean;
|
||
|
}[];
|
||
|
/** Whether this rule is enabled */
|
||
|
enabled: boolean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Configuration for email domain-based routing
|
||
|
*/
|
||
|
export interface IEmailDomainRoutingConfig {
|
||
|
/** Whether domain-based routing is enabled */
|
||
|
enabled: boolean;
|
||
|
/** Routing rules list */
|
||
|
rules: IEmailRoutingRule[];
|
||
|
/** Domain groups for organization */
|
||
|
domainGroups?: IDomainGroup[];
|
||
|
/** Default target server for unmatched domains */
|
||
|
defaultTargetServer?: string;
|
||
|
/** Default target port for unmatched domains */
|
||
|
defaultTargetPort?: number;
|
||
|
/** Whether to use TLS for the default route */
|
||
|
defaultUseTls?: boolean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Class for managing domain-based email routing
|
||
|
*/
|
||
|
export class EmailDomainRouter {
|
||
|
/** Configuration for domain-based routing */
|
||
|
private config: IEmailDomainRoutingConfig;
|
||
|
/** Domain groups indexed by ID */
|
||
|
private domainGroups: Map<string, IDomainGroup> = new Map();
|
||
|
/** Sorted rules cache for faster processing */
|
||
|
private sortedRules: IEmailRoutingRule[] = [];
|
||
|
/** Whether the rules need to be re-sorted */
|
||
|
private rulesSortNeeded = true;
|
||
|
|
||
|
/**
|
||
|
* Create a new EmailDomainRouter
|
||
|
* @param config Configuration for domain-based routing
|
||
|
*/
|
||
|
constructor(config: IEmailDomainRoutingConfig) {
|
||
|
this.config = config;
|
||
|
this.initialize();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initialize the domain router
|
||
|
*/
|
||
|
private initialize(): void {
|
||
|
// Return early if routing is not enabled
|
||
|
if (!this.config.enabled) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Initialize domain groups
|
||
|
if (this.config.domainGroups) {
|
||
|
for (const group of this.config.domainGroups) {
|
||
|
this.domainGroups.set(group.id, group);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Sort rules by priority
|
||
|
this.sortRules();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Sort rules by priority (higher first)
|
||
|
*/
|
||
|
private sortRules(): void {
|
||
|
if (!this.config.rules || !this.config.enabled) {
|
||
|
this.sortedRules = [];
|
||
|
this.rulesSortNeeded = false;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.sortedRules = [...this.config.rules]
|
||
|
.filter(rule => rule.enabled)
|
||
|
.sort((a, b) => b.priority - a.priority);
|
||
|
|
||
|
this.rulesSortNeeded = false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a new routing rule
|
||
|
* @param rule The routing rule to add
|
||
|
*/
|
||
|
public addRule(rule: IEmailRoutingRule): void {
|
||
|
if (!this.config.rules) {
|
||
|
this.config.rules = [];
|
||
|
}
|
||
|
|
||
|
// Check if rule already exists
|
||
|
const existingIndex = this.config.rules.findIndex(r => r.id === rule.id);
|
||
|
if (existingIndex >= 0) {
|
||
|
// Update existing rule
|
||
|
this.config.rules[existingIndex] = rule;
|
||
|
} else {
|
||
|
// Add new rule
|
||
|
this.config.rules.push(rule);
|
||
|
}
|
||
|
|
||
|
this.rulesSortNeeded = true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove a routing rule by ID
|
||
|
* @param ruleId ID of the rule to remove
|
||
|
* @returns Whether the rule was removed
|
||
|
*/
|
||
|
public removeRule(ruleId: string): boolean {
|
||
|
if (!this.config.rules) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const initialLength = this.config.rules.length;
|
||
|
this.config.rules = this.config.rules.filter(rule => rule.id !== ruleId);
|
||
|
|
||
|
if (initialLength !== this.config.rules.length) {
|
||
|
this.rulesSortNeeded = true;
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a domain group
|
||
|
* @param group The domain group to add
|
||
|
*/
|
||
|
public addDomainGroup(group: IDomainGroup): void {
|
||
|
if (!this.config.domainGroups) {
|
||
|
this.config.domainGroups = [];
|
||
|
}
|
||
|
|
||
|
// Check if group already exists
|
||
|
const existingIndex = this.config.domainGroups.findIndex(g => g.id === group.id);
|
||
|
if (existingIndex >= 0) {
|
||
|
// Update existing group
|
||
|
this.config.domainGroups[existingIndex] = group;
|
||
|
} else {
|
||
|
// Add new group
|
||
|
this.config.domainGroups.push(group);
|
||
|
}
|
||
|
|
||
|
// Update domain groups map
|
||
|
this.domainGroups.set(group.id, group);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove a domain group by ID
|
||
|
* @param groupId ID of the group to remove
|
||
|
* @returns Whether the group was removed
|
||
|
*/
|
||
|
public removeDomainGroup(groupId: string): boolean {
|
||
|
if (!this.config.domainGroups) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const initialLength = this.config.domainGroups.length;
|
||
|
this.config.domainGroups = this.config.domainGroups.filter(group => group.id !== groupId);
|
||
|
|
||
|
if (initialLength !== this.config.domainGroups.length) {
|
||
|
this.domainGroups.delete(groupId);
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determine routing for an email
|
||
|
* @param fromDomain The sender domain
|
||
|
* @param toDomain The recipient domain
|
||
|
* @returns Routing decision or null if no matching rule
|
||
|
*/
|
||
|
public getRoutingForEmail(fromDomain: string, toDomain: string): {
|
||
|
targetServer: string;
|
||
|
targetPort: number;
|
||
|
useTls: boolean;
|
||
|
auth?: {
|
||
|
username?: string;
|
||
|
password?: string;
|
||
|
type?: 'PLAIN' | 'LOGIN' | 'OAUTH2';
|
||
|
};
|
||
|
headers?: {
|
||
|
name: string;
|
||
|
value: string;
|
||
|
append?: boolean;
|
||
|
}[];
|
||
|
} | null {
|
||
|
// Return default routing if routing is not enabled
|
||
|
if (!this.config.enabled) {
|
||
|
return this.getDefaultRouting();
|
||
|
}
|
||
|
|
||
|
// Sort rules if needed
|
||
|
if (this.rulesSortNeeded) {
|
||
|
this.sortRules();
|
||
|
}
|
||
|
|
||
|
// Normalize domains
|
||
|
fromDomain = fromDomain.toLowerCase();
|
||
|
toDomain = toDomain.toLowerCase();
|
||
|
|
||
|
// Check each rule in priority order
|
||
|
for (const rule of this.sortedRules) {
|
||
|
if (!rule.enabled) continue;
|
||
|
|
||
|
// Check if rule applies to this email
|
||
|
if (this.ruleMatchesEmail(rule, fromDomain, toDomain)) {
|
||
|
// Handle different actions
|
||
|
switch (rule.action) {
|
||
|
case 'route':
|
||
|
// Return routing information
|
||
|
return {
|
||
|
targetServer: rule.targetServer || this.config.defaultTargetServer || 'localhost',
|
||
|
targetPort: rule.targetPort || this.config.defaultTargetPort || 25,
|
||
|
useTls: rule.useTls ?? this.config.defaultUseTls ?? false,
|
||
|
auth: rule.auth,
|
||
|
headers: rule.headers
|
||
|
};
|
||
|
case 'block':
|
||
|
// Return null to indicate email should be blocked
|
||
|
return null;
|
||
|
case 'tag':
|
||
|
case 'filter':
|
||
|
// For tagging/filtering, we need to apply headers but continue checking rules
|
||
|
// This is simplified for now, in a real implementation we'd aggregate headers
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// No rule matched, use default routing
|
||
|
return this.getDefaultRouting();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if a rule matches an email
|
||
|
* @param rule The routing rule to check
|
||
|
* @param fromDomain The sender domain
|
||
|
* @param toDomain The recipient domain
|
||
|
* @returns Whether the rule matches the email
|
||
|
*/
|
||
|
private ruleMatchesEmail(rule: IEmailRoutingRule, fromDomain: string, toDomain: string): boolean {
|
||
|
// Check source domains
|
||
|
if (rule.sourceDomains && rule.sourceDomains.length > 0) {
|
||
|
const matchesSourceDomain = rule.sourceDomains.some(
|
||
|
pattern => this.domainMatchesPattern(fromDomain, pattern)
|
||
|
);
|
||
|
if (!matchesSourceDomain) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Check destination domains
|
||
|
if (rule.destinationDomains && rule.destinationDomains.length > 0) {
|
||
|
const matchesDestinationDomain = rule.destinationDomains.some(
|
||
|
pattern => this.domainMatchesPattern(toDomain, pattern)
|
||
|
);
|
||
|
if (!matchesDestinationDomain) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Check domain groups
|
||
|
if (rule.domainGroups && rule.domainGroups.length > 0) {
|
||
|
// Check if either domain is in any of the specified groups
|
||
|
const domainsInGroups = rule.domainGroups
|
||
|
.map(groupId => this.domainGroups.get(groupId))
|
||
|
.filter(Boolean)
|
||
|
.some(group =>
|
||
|
group.domains.includes(fromDomain) ||
|
||
|
group.domains.includes(toDomain)
|
||
|
);
|
||
|
|
||
|
if (!domainsInGroups) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If we got here, all checks passed
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if a domain matches a pattern
|
||
|
* @param domain The domain to check
|
||
|
* @param pattern The pattern to match against
|
||
|
* @returns Whether the domain matches the pattern
|
||
|
*/
|
||
|
private domainMatchesPattern(domain: string, pattern: IDomainPattern): boolean {
|
||
|
domain = domain.toLowerCase();
|
||
|
const patternStr = pattern.pattern.toLowerCase();
|
||
|
|
||
|
// Exact match
|
||
|
if (!pattern.isWildcard) {
|
||
|
return domain === patternStr;
|
||
|
}
|
||
|
|
||
|
// Wildcard match (*.example.com)
|
||
|
if (patternStr.startsWith('*.')) {
|
||
|
const suffix = patternStr.substring(2);
|
||
|
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||
|
}
|
||
|
|
||
|
// Invalid pattern
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get default routing information
|
||
|
* @returns Default routing or null if no default configured
|
||
|
*/
|
||
|
private getDefaultRouting(): {
|
||
|
targetServer: string;
|
||
|
targetPort: number;
|
||
|
useTls: boolean;
|
||
|
} | null {
|
||
|
if (!this.config.defaultTargetServer) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
targetServer: this.config.defaultTargetServer,
|
||
|
targetPort: this.config.defaultTargetPort || 25,
|
||
|
useTls: this.config.defaultUseTls || false
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the current configuration
|
||
|
* @returns Current domain routing configuration
|
||
|
*/
|
||
|
public getConfig(): IEmailDomainRoutingConfig {
|
||
|
return this.config;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Update the configuration
|
||
|
* @param config New domain routing configuration
|
||
|
*/
|
||
|
public updateConfig(config: IEmailDomainRoutingConfig): void {
|
||
|
this.config = config;
|
||
|
this.rulesSortNeeded = true;
|
||
|
this.initialize();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Enable domain routing
|
||
|
*/
|
||
|
public enable(): void {
|
||
|
this.config.enabled = true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Disable domain routing
|
||
|
*/
|
||
|
public disable(): void {
|
||
|
this.config.enabled = false;
|
||
|
}
|
||
|
}
|