From ce3d3cf8b7490792e978f02a6b2d5bcd803e3b63 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:59:33 -0300 Subject: [PATCH] feat(f4): load SMTP from encrypted superadmin settings --- .../smtp-delivery.service.ts | 178 +++++++++++++++++- 1 file changed, 169 insertions(+), 9 deletions(-) diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index bce88d6..0e54de4 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -1,13 +1,27 @@ +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 { randomUUID } from 'node:crypto'; -import { ConfigService } from '@nestjs/config'; 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')); @@ -20,18 +34,164 @@ class SmtpSession { 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();} - current(){return this.socket;} } 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){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`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}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); } +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 config:ConfigService){} - configured(){return Boolean(this.config.get('SMTP_HOST')&&this.config.get('MAIL_FROM'));} - async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get('SMTP_HOST'); const from=this.config.get('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get('SMTP_PORT')??587); const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get('SMTP_USER')??''; const pass=this.config.get('SMTP_PASS')??''; const base=await new Promise((resolve,reject)=>{ if(secure){ 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(!secure){ 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(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } } - 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)}\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()}; } + constructor( + private readonly dataSource:DataSource, + private readonly config:ConfigService, + ){} + + async configured():Promise{return Boolean(await this.resolveSettings());} + + async fromAddress():Promise{ + 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>; + 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((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():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 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'); + } }