198 lines
12 KiB
TypeScript
198 lines
12 KiB
TypeScript
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
|
import { connect as connectNet, Socket } from 'node:net';
|
|
import { connect as connectTls, TLSSocket } from 'node:tls';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource } from 'typeorm';
|
|
import { SmtpSecurityMode } from '../database/entities';
|
|
|
|
interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
|
|
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
|
|
interface Reply { code:number; text:string; }
|
|
|
|
export interface EffectiveSmtpSettings {
|
|
source: 'DATABASE' | 'ENVIRONMENT';
|
|
host: string;
|
|
port: number;
|
|
securityMode: SmtpSecurityMode;
|
|
username: string | null;
|
|
password: string;
|
|
fromName: string | null;
|
|
fromEmail: string;
|
|
replyTo: string | null;
|
|
}
|
|
|
|
class SmtpSession {
|
|
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
|
|
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
|
|
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
|
|
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); }
|
|
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; }
|
|
replaceSocket(socket:Socket|TLSSocket){ this.socket=socket; this.buffer=''; this.attach(socket); }
|
|
private onData(chunk:string){ this.buffer+=chunk; const lines=this.buffer.split(/\r?\n/); this.buffer=lines.pop()??''; for(const line of lines){ if(!line)continue; this.replyLines.push(line); if(/^\d{3} /.test(line)){ const code=Number(line.slice(0,3)); const reply={code,text:this.replyLines.join('\n')}; this.replyLines=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } }
|
|
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(reply)}); });}
|
|
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; }
|
|
write(data:string){this.socket.write(data);}
|
|
end(){this.socket.end();}
|
|
}
|
|
|
|
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
|
|
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
|
|
function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); }
|
|
|
|
@Injectable()
|
|
export class SmtpDeliveryService {
|
|
constructor(
|
|
private readonly dataSource:DataSource,
|
|
private readonly config:ConfigService,
|
|
){}
|
|
|
|
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());}
|
|
|
|
async fromAddress():Promise<string|null>{
|
|
const settings=await this.resolveSettings();
|
|
if(!settings)return null;
|
|
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
|
|
}
|
|
|
|
async publicSettings(){
|
|
const [row]=await this.dataSource.query(`
|
|
SELECT id,host,port,security_mode AS "securityMode",username,
|
|
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
|
|
from_email AS "fromEmail",reply_to AS "replyTo",enabled,
|
|
updated_at AS "updatedAt"
|
|
FROM system_smtp_settings ORDER BY created_at LIMIT 1
|
|
`) as Array<Record<string,unknown>>;
|
|
if(row)return {...row,source:'DATABASE'};
|
|
const fallback=await this.resolveEnvironment();
|
|
return fallback?{
|
|
source:'ENVIRONMENT',host:fallback.host,port:fallback.port,
|
|
securityMode:fallback.securityMode,username:fallback.username,
|
|
hasPassword:Boolean(fallback.password),fromName:fallback.fromName,
|
|
fromEmail:fallback.fromEmail,replyTo:fallback.replyTo,enabled:true,
|
|
}:{source:'NONE',enabled:false};
|
|
}
|
|
|
|
async saveSettings(input:{
|
|
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
|
|
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
|
|
},userId:string){
|
|
const [existing]=await this.dataSource.query(`SELECT id,password_enc AS "passwordEnc" FROM system_smtp_settings ORDER BY created_at LIMIT 1`) as Array<{id:string;passwordEnc:string|null}>;
|
|
const passwordEnc=input.password===undefined
|
|
? existing?.passwordEnc??null
|
|
: input.password?this.encryptSecret(input.password):null;
|
|
if(existing){
|
|
await this.dataSource.query(`
|
|
UPDATE system_smtp_settings SET host=$2,port=$3,security_mode=$4,username=$5,
|
|
password_enc=$6,from_name=$7,from_email=$8,reply_to=$9,enabled=$10,
|
|
updated_by=$11,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
|
`,[existing.id,input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
|
|
}else{
|
|
await this.dataSource.query(`
|
|
INSERT INTO system_smtp_settings(host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
|
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
`,[input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
|
|
}
|
|
return this.publicSettings();
|
|
}
|
|
|
|
async send(input:MailInput):Promise<{messageId:string}>{
|
|
const settings=await this.resolveSettings();
|
|
if(!settings)throw new Error('SMTP no configurado');
|
|
const {host,port,securityMode,userName,password}= {
|
|
host:settings.host,port:settings.port,securityMode:settings.securityMode,
|
|
userName:settings.username??'',password:settings.password,
|
|
};
|
|
const implicitTls=securityMode===SmtpSecurityMode.TLS;
|
|
const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{
|
|
if(implicitTls){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }
|
|
else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); }
|
|
});
|
|
const session=new SmtpSession(base);
|
|
const welcome=await session.reply();
|
|
if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`);
|
|
let ehlo=await session.command('EHLO dh-inspeccion',250);
|
|
if(securityMode===SmtpSecurityMode.STARTTLS){
|
|
if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS');
|
|
await session.command('STARTTLS',220);
|
|
const rawForTls=session.detachForUpgrade() as Socket;
|
|
const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); });
|
|
session.replaceSocket(upgraded);
|
|
ehlo=await session.command('EHLO dh-inspeccion',250);
|
|
}
|
|
if(userName){
|
|
if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${userName}\u0000${password}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); }
|
|
else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(userName).toString('base64'),334); await session.command(Buffer.from(password).toString('base64'),235); }
|
|
}
|
|
const from=settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
|
|
await session.command(`MAIL FROM:<${envelope(from)}>`,250);
|
|
await session.command(`RCPT TO:<${input.to}>`,[250,251]);
|
|
await session.command('DATA',354);
|
|
session.write(`${mime(input,from,settings.replyTo)}\r\n.\r\n`);
|
|
const sent=await session.reply();
|
|
if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`);
|
|
await session.command('QUIT',221).catch(()=>undefined);
|
|
session.end();
|
|
const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
|
|
return {messageId:match?.[1]??randomUUID()};
|
|
}
|
|
|
|
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{
|
|
const [row]=await this.dataSource.query(`
|
|
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
|
|
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled
|
|
FROM system_smtp_settings ORDER BY created_at LIMIT 1
|
|
`) as Array<{
|
|
host:string;port:number;securityMode:SmtpSecurityMode;username:string|null;passwordEnc:string|null;
|
|
fromName:string;fromEmail:string;replyTo:string|null;enabled:boolean;
|
|
}>;
|
|
if(row){
|
|
if(!row.enabled)return null;
|
|
return {
|
|
source:'DATABASE',host:row.host,port:Number(row.port),securityMode:row.securityMode,
|
|
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
|
|
fromName:row.fromName,fromEmail:row.fromEmail,replyTo:row.replyTo,
|
|
};
|
|
}
|
|
return this.resolveEnvironment();
|
|
}
|
|
|
|
private async resolveEnvironment():Promise<EffectiveSmtpSettings|null>{
|
|
const host=this.config.get<string>('SMTP_HOST');
|
|
const rawFrom=this.config.get<string>('MAIL_FROM');
|
|
if(!host||!rawFrom)return null;
|
|
const fromEmail=envelope(rawFrom);
|
|
const fromName=rawFrom.includes('<')?rawFrom.slice(0,rawFrom.indexOf('<')).trim().replace(/^"|"$/g,'')||null:null;
|
|
const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true';
|
|
return {
|
|
source:'ENVIRONMENT',host,port:Number(this.config.get<string>('SMTP_PORT')??(secure?465:587)),
|
|
securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS,
|
|
username:this.config.get<string>('SMTP_USER')??null,password:this.config.get<string>('SMTP_PASS')??'',
|
|
fromName,fromEmail,replyTo:null,
|
|
};
|
|
}
|
|
|
|
private encryptionKey():Buffer{
|
|
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
|
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
|
|
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
|
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
|
|
return key;
|
|
}
|
|
|
|
private encryptSecret(value:string):string{
|
|
const iv=randomBytes(12); const cipher=createCipheriv('aes-256-gcm',this.encryptionKey(),iv);
|
|
const encrypted=Buffer.concat([cipher.update(value,'utf8'),cipher.final()]);
|
|
const tag=cipher.getAuthTag();
|
|
return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
|
}
|
|
|
|
private decryptSecret(value:string):string{
|
|
const [version,ivB64,tagB64,cipherB64]=value.split(':');
|
|
if(version!=='v1'||!ivB64||!tagB64||!cipherB64)throw new Error('Credencial SMTP cifrada inválida');
|
|
const decipher=createDecipheriv('aes-256-gcm',this.encryptionKey(),Buffer.from(ivB64,'base64'));
|
|
decipher.setAuthTag(Buffer.from(tagB64,'base64'));
|
|
return Buffer.concat([decipher.update(Buffer.from(cipherB64,'base64')),decipher.final()]).toString('utf8');
|
|
}
|
|
}
|