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, ServiceUnavailableException } 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: 'USER' | '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{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(userId?:string):Promise{return Boolean(await this.resolveSettings(userId));} async fromAddress(userId?:string):Promise{ const settings=await this.resolveSettings(userId); 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>; 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 publicUserSettings(userId:string){ const [user]=await this.dataSource.query(` SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1 `,[userId]) as Array<{email:string;firstName:string;lastName:string}>; if(!user)throw new Error('Usuario no encontrado'); const [row]=await this.dataSource.query(` SELECT mode,host,port,security_mode AS "securityMode",username, (password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName", from_email AS "fromEmail",enabled,updated_at AS "updatedAt" FROM user_smtp_settings WHERE user_id=$1 `,[userId]) as Array>; return { mode:row?.mode??'SYSTEM',email:user.email,generalConfigured:Boolean(await this.resolveSystemSettings()), custom:row?{ host:row.host??'',port:row.port??587,securityMode:row.securityMode??'STARTTLS', username:row.username??'',hasPassword:Boolean(row.hasPassword), fromName:row.fromName??`${user.firstName} ${user.lastName}`,fromEmail:row.fromEmail??user.email, enabled:row.enabled!==false,updatedAt:row.updatedAt??null, }:null, }; } async saveUserSettings(userId:string,input:{ mode:'SYSTEM'|'CUSTOM';host?:string;port?:number;securityMode?:SmtpSecurityMode; username?:string|null;password?:string|null;fromName?:string|null;enabled?:boolean; }){ const [user]=await this.dataSource.query(`SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1`,[userId]) as Array<{email:string;firstName:string;lastName:string}>; if(!user?.email)throw new Error('El usuario debe tener un email configurado'); const [existing]=await this.dataSource.query(`SELECT password_enc AS "passwordEnc" FROM user_smtp_settings WHERE user_id=$1`,[userId]) as Array<{passwordEnc:string|null}>; if(input.mode==='SYSTEM'){ await this.dataSource.query(` INSERT INTO user_smtp_settings(user_id,mode,updated_by) VALUES($1,'SYSTEM',$1) ON CONFLICT(user_id) DO UPDATE SET mode='SYSTEM',updated_by=$1,updated_at=CURRENT_TIMESTAMP `,[userId]); return this.publicUserSettings(userId); } if(!input.host||!input.port||!input.securityMode)throw new Error('La configuración SMTP propia está incompleta'); const passwordEnc=input.password===undefined?existing?.passwordEnc??null:input.password?this.encryptSecret(input.password):null; const fromName=input.fromName?.trim()||`${user.firstName} ${user.lastName}`.trim(); await this.dataSource.query(` INSERT INTO user_smtp_settings(user_id,mode,host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by) VALUES($1,'CUSTOM',$2,$3,$4,$5,$6,$7,$8,$8,$9,$1) ON CONFLICT(user_id) DO UPDATE SET mode='CUSTOM',host=EXCLUDED.host,port=EXCLUDED.port, security_mode=EXCLUDED.security_mode,username=EXCLUDED.username,password_enc=EXCLUDED.password_enc, from_name=EXCLUDED.from_name,from_email=EXCLUDED.from_email,reply_to=EXCLUDED.reply_to, enabled=EXCLUDED.enabled,updated_by=$1,updated_at=CURRENT_TIMESTAMP `,[userId,input.host,input.port,input.securityMode,input.username?.trim()||null,passwordEnc,fromName,user.email,input.enabled!==false]); return this.publicUserSettings(userId); } 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,userId?:string):Promise<{messageId:string}>{ const settings=await this.resolveSettings(userId); 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((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((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(userId?:string):Promise{ if(userId){ const [row]=await this.dataSource.query(` SELECT settings.mode,settings.host,settings.port,settings.security_mode AS "securityMode", settings.username,settings.password_enc AS "passwordEnc",settings.from_name AS "fromName", settings.enabled,user_account.email AS "userEmail" FROM users user_account LEFT JOIN user_smtp_settings settings ON settings.user_id=user_account.id WHERE user_account.id=$1 `,[userId]) as Array<{ mode:'SYSTEM'|'CUSTOM'|null;host:string|null;port:number|null;securityMode:SmtpSecurityMode|null; username:string|null;passwordEnc:string|null;fromName:string|null;enabled:boolean|null;userEmail:string; }>; if(row?.mode==='CUSTOM'&&row.enabled!==false&&row.host&&row.port&&row.securityMode&&row.userEmail){ return { source:'USER',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.userEmail,replyTo:row.userEmail, }; } const general=await this.resolveSystemSettings(); return general&&row?.userEmail?{...general,replyTo:row.userEmail}:general; } return this.resolveSystemSettings(); } private async resolveSystemSettings():Promise{ 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{ const host=this.config.get('SMTP_HOST'); const rawFrom=this.config.get('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('SMTP_SECURE')??'false').toLowerCase()==='true'; return { source:'ENVIRONMENT',host,port:Number(this.config.get('SMTP_PORT')??(secure?465:587)), securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS, username:this.config.get('SMTP_USER')??null,password:this.config.get('SMTP_PASS')??'', fromName,fromEmail,replyTo:null, }; } private encryptionKey():Buffer{ const raw=this.config.get('SMTP_SETTINGS_MASTER_KEY'); if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' }); const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' }); 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'); } }