fix(mail): align queue, outbound hostname, and DKIM selector behavior across the mail server APIs

This commit is contained in:
2026-04-14 12:17:50 +00:00
parent 04e73c366c
commit 65ecd94540
15 changed files with 387 additions and 147 deletions

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import { type IStorageManagerLike, hasStorageManagerMethods } from '../interfaces.storage.js';
import { Email } from '../core/classes.email.js';
// MtaService reference removed
@@ -24,13 +25,47 @@ export interface IDkimKeyMetadata {
export class DKIMCreator {
private keysDir: string;
private storageManager?: any; // StorageManager instance
private storageManager?: IStorageManagerLike;
constructor(keysDir = paths.keysDir, storageManager?: any) {
constructor(keysDir = paths.keysDir, storageManager?: IStorageManagerLike) {
this.keysDir = keysDir;
this.storageManager = storageManager;
}
private async writeKeyPairToFilesystem(
privateKeyPath: string,
publicKeyPath: string,
privateKey: string,
publicKey: string,
): Promise<void> {
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
}
private async storeLegacyKeysToStorage(domain: string, privateKey: string, publicKey: string): Promise<void> {
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
return;
}
await Promise.all([
this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey),
]);
}
private async storeSelectorKeysToStorage(
domain: string,
selector: string,
privateKey: string,
publicKey: string,
): Promise<void> {
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
return;
}
await Promise.all([
this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey),
]);
}
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
return {
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
@@ -51,6 +86,20 @@ export class DKIMCreator {
}
}
public async handleDKIMKeysForSelector(domainArg: string, selector: string = 'default', keySize: number = 2048): Promise<void> {
if (selector === 'default') {
await this.handleDKIMKeysForDomain(domainArg);
return;
}
try {
await this.readDKIMKeysForSelector(domainArg, selector);
} catch {
console.log(`No DKIM keys found for ${domainArg}/${selector}. Generating...`);
await this.createAndStoreDKIMKeysForSelector(domainArg, selector, keySize);
}
}
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
const domain = email.from.split('@')[1];
await this.handleDKIMKeysForDomain(domain);
@@ -59,7 +108,7 @@ export class DKIMCreator {
// Read DKIM keys - always use storage manager, migrate from filesystem if needed
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
// Try to read from storage manager first
if (this.storageManager) {
if (hasStorageManagerMethods(this.storageManager, ['get', 'set'])) {
try {
const [privateKey, publicKey] = await Promise.all([
this.storageManager.get(`/email/dkim/${domainArg}/private.key`),
@@ -87,10 +136,7 @@ export class DKIMCreator {
// Migrate to storage manager
console.log(`Migrating DKIM keys for ${domainArg} from filesystem to StorageManager`);
await Promise.all([
this.storageManager.set(`/email/dkim/${domainArg}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domainArg}/public.key`, publicKey)
]);
await this.storeLegacyKeysToStorage(domainArg, privateKey, publicKey);
return { privateKey, publicKey };
} catch (error) {
@@ -116,9 +162,9 @@ export class DKIMCreator {
}
// Create an RSA DKIM key pair - changed to public for API access
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
public async createDKIMKeys(keySize: number = 2048): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('rsa', {
modulusLength: 2048,
modulusLength: keySize,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
});
@@ -136,75 +182,58 @@ export class DKIMCreator {
return { privateKey, publicKey };
}
// Store a DKIM key pair - uses storage manager if available, else disk
public async storeDKIMKeys(
privateKey: string,
publicKey: string,
privateKeyPath: string,
publicKeyPath: string
): Promise<void> {
// Store in storage manager if available
if (this.storageManager) {
// Extract domain from path (e.g., /path/to/keys/example.com-private.pem -> example.com)
const match = privateKeyPath.match(/\/([^\/]+)-private\.pem$/);
if (match) {
const domain = match[1];
await Promise.all([
this.storageManager.set(`/email/dkim/${domain}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domain}/public.key`, publicKey)
]);
}
}
// Also store to filesystem for backward compatibility
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
}
// Create a DKIM key pair and store it to disk - changed to public for API access
public async createAndStoreDKIMKeys(domain: string): Promise<void> {
const { privateKey, publicKey } = await this.createDKIMKeys();
public async createAndStoreDKIMKeys(domain: string, keySize: number = 2048): Promise<void> {
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
const keyPaths = await this.getKeyPathsForDomain(domain);
await this.storeDKIMKeys(
privateKey,
publicKey,
keyPaths.privateKeyPath,
keyPaths.publicKeyPath
);
await this.storeLegacyKeysToStorage(domain, privateKey, publicKey);
await this.writeKeyPairToFilesystem(keyPaths.privateKeyPath, keyPaths.publicKeyPath, privateKey, publicKey);
await this.saveKeyMetadata({
domain,
selector: 'default',
createdAt: Date.now(),
keySize,
});
console.log(`DKIM keys for ${domain} created and stored.`);
}
public async createAndStoreDKIMKeysForSelector(
domain: string,
selector: string,
keySize: number = 2048,
): Promise<void> {
if (selector === 'default') {
await this.createAndStoreDKIMKeys(domain, keySize);
return;
}
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
await this.storeSelectorKeysToStorage(domain, selector, privateKey, publicKey);
await this.writeKeyPairToFilesystem(keyPaths.privateKeyPath, keyPaths.publicKeyPath, privateKey, publicKey);
await this.saveKeyMetadata({
domain,
selector,
createdAt: Date.now(),
keySize,
});
console.log(`DKIM keys for ${domain}/${selector} created and stored.`);
}
// Changed to public for API access
public async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
await this.handleDKIMKeysForDomain(domainArg);
const keys = await this.readDKIMKeys(domainArg);
// Remove the PEM header and footer and newlines
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const keyContents = keys.publicKey
.replace(pemHeader, '')
.replace(pemFooter, '')
.replace(/\n/g, '');
// Detect key type from PEM header
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
// Now generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
return {
name: `mta._domainkey.${domainArg}`,
type: 'TXT',
dnsSecEnabled: null,
value: dnsRecordValue,
};
public async getDNSRecordForDomain(
domainArg: string,
selector: string = 'default',
): Promise<plugins.tsclass.network.IDnsRecord> {
await this.handleDKIMKeysForSelector(domainArg, selector);
return this.getDNSRecordForSelector(domainArg, selector);
}
/**
* Get DKIM key metadata for a domain
*/
private async getKeyMetadata(domain: string, selector: string = 'default'): Promise<IDkimKeyMetadata | null> {
if (!this.storageManager) {
if (!hasStorageManagerMethods(this.storageManager, ['get'])) {
return null;
}
@@ -222,7 +251,7 @@ export class DKIMCreator {
* Save DKIM key metadata
*/
private async saveKeyMetadata(metadata: IDkimKeyMetadata): Promise<void> {
if (!this.storageManager) {
if (!hasStorageManagerMethods(this.storageManager, ['set'])) {
return;
}
@@ -259,30 +288,16 @@ export class DKIMCreator {
const newSelector = `key${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
// Create new keys with custom key size
const { privateKey, publicKey } = await generateKeyPair('rsa', {
modulusLength: keySize,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
});
const { privateKey, publicKey } = await this.createDKIMKeys(keySize);
// Store new keys with new selector
const newKeyPaths = await this.getKeyPathsForSelector(domain, newSelector);
// Store in storage manager if available
if (this.storageManager) {
await Promise.all([
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domain}/${newSelector}/public.key`, publicKey)
]);
}
await this.storeSelectorKeysToStorage(domain, newSelector, privateKey, publicKey);
// Also store to filesystem
await this.storeDKIMKeys(
privateKey,
publicKey,
newKeyPaths.privateKeyPath,
newKeyPaths.publicKeyPath
);
await this.writeKeyPairToFilesystem(newKeyPaths.privateKeyPath, newKeyPaths.publicKeyPath, privateKey, publicKey);
// Save metadata for new keys
const metadata: IDkimKeyMetadata = {
@@ -320,7 +335,7 @@ export class DKIMCreator {
*/
public async readDKIMKeysForSelector(domain: string, selector: string): Promise<{ privateKey: string; publicKey: string }> {
// Try to read from storage manager first
if (this.storageManager) {
if (hasStorageManagerMethods(this.storageManager, ['get', 'set'])) {
try {
const [privateKey, publicKey] = await Promise.all([
this.storageManager.get(`/email/dkim/${domain}/${selector}/private.key`),
@@ -330,6 +345,10 @@ export class DKIMCreator {
if (privateKey && publicKey) {
return { privateKey, publicKey };
}
if (selector === 'default') {
return await this.readDKIMKeys(domain);
}
} catch (error) {
// Fall through to migration check
}
@@ -347,10 +366,7 @@ export class DKIMCreator {
// Migrate to storage manager
console.log(`Migrating DKIM keys for ${domain}/${selector} from filesystem to StorageManager`);
await Promise.all([
this.storageManager.set(`/email/dkim/${domain}/${selector}/private.key`, privateKey),
this.storageManager.set(`/email/dkim/${domain}/${selector}/public.key`, publicKey)
]);
await this.storeSelectorKeysToStorage(domain, selector, privateKey, publicKey);
return { privateKey, publicKey };
} catch (error) {
@@ -361,6 +377,9 @@ export class DKIMCreator {
}
} else {
// No storage manager, use filesystem directly
if (selector === 'default') {
return this.readDKIMKeys(domain);
}
const keyPaths = await this.getKeyPathsForSelector(domain, selector);
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
readFile(keyPaths.privateKeyPath),
@@ -406,7 +425,8 @@ export class DKIMCreator {
* Clean up old DKIM keys after grace period
*/
public async cleanupOldKeys(domain: string, gracePeriodDays: number = 30): Promise<void> {
if (!this.storageManager) {
if (!hasStorageManagerMethods(this.storageManager, ['get', 'list', 'delete'])) {
console.log(`StorageManager for ${domain} does not support list/delete. Skipping DKIM cleanup.`);
return;
}
@@ -436,7 +456,11 @@ export class DKIMCreator {
console.warn(`Failed to delete old key files: ${error.message}`);
}
// Delete metadata
// Delete selector-specific storage keys and metadata
await Promise.all([
this.storageManager.delete(`/email/dkim/${domain}/${metadata.selector}/private.key`),
this.storageManager.delete(`/email/dkim/${domain}/${metadata.selector}/public.key`),
]);
await this.storageManager.delete(key);
}
}
@@ -444,4 +468,4 @@ export class DKIMCreator {
}
}
}
}
}