4 Commits

Author SHA1 Message Date
Your Name 0d0077cbcb smtp new account 2025-05-02 16:02:59 -06:00
Your Name c4666718a4 log 2025-05-02 15:41:20 -06:00
Your Name 11fe86a111 name stmp 2025-05-02 15:35:20 -06:00
Your Name 59a5ff8c35 smtp rellay 2025-05-02 15:10:08 -06:00
24 changed files with 4115 additions and 6804 deletions
+3729 -5824
View File
File diff suppressed because it is too large Load Diff
+24 -30
View File
@@ -20,58 +20,52 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/bull": "^11.0.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.5",
"@nestjs/typeorm": "^11.0.3",
"bull": "^4.16.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"csv-parser": "^3.2.1",
"dotenv": "^17.4.2",
"@nestjs/swagger": "^11.0.5",
"@nestjs/typeorm": "^11.0.0",
"class-validator": "^0.14.1",
"csv-parser": "^3.2.0",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"multer": "^1.4.5-lts.2",
"mysql2": "^3.23.1",
"nodemailer": "^9.0.3",
"passport": "^0.7.0",
"fs": "^0.0.1-security",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nodemailer": "^6.10.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^1.1.0"
"typeorm": "^0.3.21"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.39.5",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.1.28",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.46",
"@types/bull": "^3.15.9",
"@types/express": "^5.0.6",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.13",
"@types/node": "^22.20.1",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.9.6",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"ts-loader": "^9.6.2",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.65.0"
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
BIN
View File
Binary file not shown.
+7 -16
View File
@@ -2,40 +2,31 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MailModule } from './mail/mail.module';
import { ExcelModule } from './excel/excel.module'
import {ExcelModule} from './excel/excel.module'
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { SistemaModule } from './sistema/sistema.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
ConfigModule.forRoot(
{ isGlobal: true }
),
JwtModule.register({
secret: process.env.JWT_SECRET,
}),
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
type: 'mysql',
type: 'mysql',
host: process.env.db_host,
username: process.env.db_username,
database: process.env.db_database,
password: process.env.db_password,
port: Number(process.env.db_port),
synchronize: true,
dropSchema: false, // elimina la base de datos
dropSchema: true, // elimina la base de datos
// logging: true, // Habilita los logs para depuración
autoLoadEntities: true, // Carga automáticamente las entidades
}),
MailModule,
ExcelModule,
SistemaModule,
@@ -45,4 +36,4 @@ import { JwtModule } from '@nestjs/jwt';
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }
export class AppModule {}
-19
View File
@@ -1,19 +0,0 @@
// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
@Module({
imports: [
ConfigModule, // Ya es global, pero lo puedes dejar
JwtModule.register({
secret: process.env.JWT_SECRET
})
],
providers: [AuthService],
exports: [AuthService, JwtModule],
})
export class AuthModule { }
-26
View File
@@ -1,26 +0,0 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService) {}
generarToken(payload: any) {
console.log('JWT_SECRET:', process.env.JWT_SECRET);
return this.jwtService.sign(payload, {
secret: process.env.JWT_SECRET,
});
}
async verificarToken(token: string) {
try {
const result = await this.jwtService.verify(token, {
secret: process.env.JWT_SECRET,
});
console.log('Resultado de verificación:', result);
return result;
} catch (error) {
throw new Error('Token inválido o expirado');
}
}
}
-35
View File
@@ -1,35 +0,0 @@
import * as crypto from 'crypto';
export function encrypt(text: string) {
const secret = process.env.KEY_SECRET;
if (!secret) throw new Error('Falta la variable de entorno KEY_SECRET');
const key = crypto.scryptSync(secret, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
iv: iv.toString('hex'),
encryptedData: encrypted,
};
}
export function decrypt(encryptedData: string, ivHex: string) {
const secret = process.env.KEY_SECRET;
if (!process.env.KEY_SECRET) {
throw new Error('Falta la variable de entorno KEY_SECRET');
}
if (!secret) throw new Error('Falta la variable de entorno KEY_SECRET');
const key = crypto.scryptSync(secret, 'salt', 32);
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
+9 -9
View File
@@ -7,14 +7,14 @@ import { ApiReadEmails } from './excel.decorators';
@ApiTags('Excel') // Agrupa los endpoints en Swagger
@Controller('excel')
export class ExcelController {
constructor(private readonly excelService: ExcelService) { }
constructor(private readonly excelService: ExcelService) {}
// @Post('read-emails')
// @UseInterceptors(FileInterceptor('file')) // Permite subir archivos
// @ApiConsumes('multipart/form-data') // Indica que se usa un formulario con archivos
// @ApiReadEmails()
// async getEmails(@UploadedFile() file: Express.Multer.File) {
// const emails = await this.excelService.readEmailsFromExcel(file);
// return { emails };
// }
@Post('read-emails')
@UseInterceptors(FileInterceptor('file')) // Permite subir archivos
@ApiConsumes('multipart/form-data') // Indica que se usa un formulario con archivos
@ApiReadEmails()
async getEmails(@UploadedFile() file: Express.Multer.File) {
const emails = await this.excelService.readEmailsFromExcel(file);
return { emails };
}
}
+27 -27
View File
@@ -4,42 +4,42 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ExcelService {
// async readEmailsFromExcel(file: Express.Multer.File) {
async readEmailsFromExcel(file: Express.Multer.File) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer); // 🔹 `await` para leer el archivo correctamente
const worksheet = workbook.getWorksheet(1); // Obtiene la primera hoja
if (!worksheet) {
throw new Error(' No se encontró ninguna hoja en el archivo.');
}
const jsonData: any[] = [];
// const workbook = new ExcelJS.Workbook();
// await workbook.xlsx.load(file.buffer); // 🔹 `await` para leer el archivo correctamente
// 🔹 Iterar sobre las filas y extraer datos
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // Ignorar encabezado
// const worksheet = workbook.getWorksheet(1); // Obtiene la primera hoja
// if (!worksheet) {
// throw new Error(' No se encontró ninguna hoja en el archivo.');
// }
const rowData = {
Correo: row.getCell(1).value, // Ajusta el índice según tu archivo
};
// const jsonData: any[] = [];
jsonData.push(rowData);
});
// // 🔹 Iterar sobre las filas y extraer datos
// worksheet.eachRow((row, rowNumber) => {
// if (rowNumber === 1) return; // Ignorar encabezado
console.log(` Datos extraídos:`, jsonData);
// const rowData = {
// Correo: row.getCell(1).value, // Ajusta el índice según tu archivo
// };
// 🔹 Extraer correos de la columna "Correo"
const emails = jsonData
.map(row => row.Correo)
.filter(email => typeof email === 'string');
// jsonData.push(rowData);
// });
console.log(` Correos extraídos:`, emails);
// console.log(` Datos extraídos:`, jsonData);
// // 🔹 Extraer correos de la columna "Correo"
// const emails = jsonData
// .map(row => row.Correo)
// .filter(email => typeof email === 'string');
// console.log(` Correos extraídos:`, emails);
// return emails;
// }
return emails;
}
}
-29
View File
@@ -1,29 +0,0 @@
import { IsString, IsEmail, IsDate, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
export class SendCorreoDto {
@IsEmail()
to: string;
@IsString()
@IsOptional()
subject: string;
@IsString()
@IsOptional()
text: string;
@IsDate()
@Type(() => Date)
fecha_recibido: Date;
@IsString()
@IsOptional()
html: string;
@IsOptional()
adjuntos: any;
}
+58 -61
View File
@@ -2,101 +2,98 @@ import {
Controller,
Post,
Body,
UploadedFile,
Headers,
UnauthorizedException,
} from '@nestjs/common';
import { MailService } from './mail.service';
import { ExcelService } from 'src/excel/excel.service';
import { ApiTags } from '@nestjs/swagger';
import { ApiSendMail } from './mail.decorators';
import { SistemaService } from 'src/sistema/sistema.service';
import { SendCorreoDto } from 'src/mail/dto/send-email.dto';
import { AuthService } from 'src/auth/auth.service';
import { ApiSendMail, ApiSendMailExcel} from './mail.decorators';
import { Repository } from 'typeorm';
import { Sistema } from 'src/typeorm/votacionesPrueba.entity';
import { InjectRepository } from '@nestjs/typeorm';
@ApiTags('Mail') // Grupo de endpoints
@Controller('mail')
export class MailController {
constructor(
private readonly sistemaService: SistemaService,
private readonly mailService: MailService,
// private readonly excelService: ExcelService,
private readonly authService: AuthService,
private readonly excelService: ExcelService,
@InjectRepository(Sistema) private readonly sistemaRepository: Repository<Sistema>,
) {}
// Endpoint para enviar correos a través de una solicitud POST
@Post('send')
@ApiSendMail()
async sendMail(
@Headers() headers: { token: string },
@Body() body: SendCorreoDto,
) {
if (!headers.token) {
throw new UnauthorizedException('Header "token" es requerido');
}
@Headers() headers:{token:string},
@Body() body: { to: string; subject: string; text: string; fecha_recibido: Date, html:string, adjuntos:any }) {
const result = await this.mailService
.sendMail(body, headers.token)
.then((value) => {
console.log('ya acabe el correo', value);
})
.catch((err) => {
console.log(err);
});
// esta parte hay que rehacerla, se tiene que obtener la contraseña del sistema en base a la clave del sistema.
// debe de existir un modulo para crear contraseñas para las apis y administrar esas contraseñas
// sistem
/* if (!headers.token) {
throw new UnauthorizedException('Header "Password" es requerido');
} */
/* const sistem = await this.sistemaRepository.findOne({ where: { token: headers.token } });
if (!sistem) {
throw new UnauthorizedException('Password incorrecto');
} */
console.log("estos son los archivos adjuntos en el controller",body);
const { to, subject, text, html, fecha_recibido, adjuntos } = body;
const result = await this.mailService.sendMail(to, subject, text,html, fecha_recibido,1,adjuntos); // sistem
return result;
}
@Post('masivo')
async enviarMasivo(
@Body() data: SendCorreoDto[],
@Headers() headers: { token: string },
) {
return await this.mailService.sendBulkMail(data, headers.token);
}
/*
}
@Post('send-excel')
@UseInterceptors(FileInterceptor('file'))
@ApiSendMailExcel()
async sendMailExcel(
@Headers() headers: { token: string },
@UploadedFile() file: Express.Multer.File
) {
if (!headers.token) {
throw new UnauthorizedException('Header "token" es requerido');
async sendMailExcel(@Headers() headers:{password:string},@Body() file: Express.Multer.File) {
if (!headers.password) {
throw new UnauthorizedException('Header "Password" es requerido');
}
let payload;
try {
payload = await this.jwtService.verify(headers.token);
} catch {
throw new UnauthorizedException('Token inválido');
}
const sistem = await this.sistemaService.findByNombre(payload.nombre);
const sistem = await this.sistemaRepository.findOne({ where: { token: headers.password } });
if (!sistem) {
throw new UnauthorizedException('Sistema no encontrado');
throw new UnauthorizedException('Password incorrecto');
}
if (!file) {
throw new Error('Archivo Excel no encontrado');
}
const emails = await this.excelService.readEmailsFromExcel(file);
const fechaActual: Date = new Date();
for (const email of emails) {
}
const emails=this.excelService.readEmailsFromExcel(file);
let fechaActual: Date = new Date();
for (const email of await emails) {
try {
await this.mailService.sendMail(email, 'subject', 'text', 'html', fechaActual, sistem);
console.log(`Enviado a: ${email}`);
await this.mailService.sendMail(email, "subject", "text","html", fechaActual,sistem);
console.log(`📧 Enviado a: ${email}`);
} catch (error) {
console.error(`Error enviando a ${email}:`, error.message);
console.error(`Error enviando a ${email}:`, error.message);
}
}
return { message: 'Correos enviados (si no hubo errores)' };
}
*/
}
+70 -45
View File
@@ -1,63 +1,88 @@
import { applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiBody, ApiHeader } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiResponse } from '@nestjs/swagger';
export const ApiSendMail = () => {
return applyDecorators(
ApiOperation({
summary: 'Enviar un correo electrónico desde el sistema autenticado',
description: `Este endpoint permite a un sistema autenticado enviar un correo electrónico utilizando su configuración registrada.
Debe incluirse un token JWT válido en los encabezados (headers).
El sistema debe proporcionar el destinatario, asunto, contenido del mensaje y credenciales de su correo.`,
}),
summary: 'Enviar un correo manualmente',
description: `Este endpoint permite enviar un correo individualmente. Se requiere proporcionar:
- **to**: Dirección de correo del destinatario.
- **subject**: Asunto del correo.
- **text**: Cuerpo del mensaje.
- **fecha_recibido**: Fecha en que se registró la solicitud.
- **sistema**: contraseña del sistema.
- **html**: se puede mandar html
- **adjuntos**:mandar qr
(Se requiere una contraseña de aplicación
No caducan automáticamente.
Pero se invalidan si:
Cambias la contraseña de tu cuenta.
Desactivas la verificación en dos pasos.
Revocas el acceso desde https://myaccount.google.com/apppasswords.
Google detecta actividad sospechosa o problemas de seguridad.))`,
}),
ApiHeader({
name: 'token',
description: 'Token JWT del sistema para autenticación',
name: 'password',
description: 'Contraseña del sistema para autenticación',
required: true,
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
example: 'mi-password-segura'
}),
ApiBody({
description: 'Datos del correo a enviar',
schema: {
type: 'object',
properties: {
to: {
type: 'string',
format: 'email',
example: 'destinatario@example.com',
},
subject: {
type: 'string',
example: 'Informe Semanal',
},
text: {
type: 'string',
example: 'Aquí está el informe solicitado...',
},
html: {
type: 'string',
example: '<p>Este es un <b>mensaje</b> en HTML</p>',
},
fecha_recibido: {
type: 'string',
format: 'date-time',
example: '2025-05-01T15:00:00Z',
},
adjuntos: {
type: 'array',
description: 'Archivos adjuntos en base64',
items: {
type: 'object',
properties: {
filename: { type: 'string', example: 'reporte.pdf' },
content: { type: 'string', example: 'base64string...' },
encoding: { type: 'string', example: 'base64' },
cid: { type: 'string', example: 'file123' }
},
},
}
to: { type: 'string', example: 'usuario@example.com' },
subject: { type: 'string', example: 'Asunto del correo' },
text: { type: 'string', example: 'Cuerpo del mensaje' },
fecha_recibido: { type: 'string', format: 'date-time', example: '2025-03-10T12:00:00Z' }
},
required: ['to', 'fecha_recibido'],
},
})
);
};
export const ApiSendMailExcel = () => {
return applyDecorators(
ApiOperation({
summary: 'Enviar correos desde un archivo Excel',
description: `Este endpoint permite enviar correos a partir de una lista contenida en un archivo Excel.
El archivo debe contener una columna con direcciones de correo válidas.`,
}),
ApiConsumes('multipart/form-data'),
ApiBody({
description: 'Sube un archivo Excel con correos',
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
},
},
}),
ApiResponse({
status: 200,
description: 'Correos enviados exitosamente',
schema: {
example: {
message: 'Correos enviados correctamente',
emails: ['usuario1@example.com', 'usuario2@example.com'],
},
},
}),
ApiResponse({
status: 400,
description: 'Error al procesar el archivo',
schema: {
example: {
statusCode: 400,
message: 'El archivo no es un Excel válido',
error: 'Bad Request',
},
},
}),
);
};
+2 -4
View File
@@ -7,13 +7,11 @@ import {
Status,
Correo,
Sistema,
}from 'src/typeorm/entidades'
}from 'src/typeorm/votacionesPrueba.entity'
import { SistemaModule } from 'src/sistema/sistema.module';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from 'src/auth/auth.service';
@Module({
providers: [MailService,JwtService,AuthService],
providers: [MailService],
imports: [
ExcelModule,
SistemaModule,
+1 -1
View File
@@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { MailService } from './mail.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Correo, Sistema, Status } from '../typeorm/entidades'; // Ajusta si es necesario
import { Correo, Sistema, Status } from '../typeorm/votacionesPrueba.entity'; // Ajusta si es necesario
describe('MailService', () => {
let service: MailService;
+118 -268
View File
@@ -1,51 +1,68 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import * as dotenv from 'dotenv';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Correo, Sistema, Status } from '../typeorm/entidades';
import { SistemaService } from 'src/sistema/sistema.service';
import { SendCorreoDto } from 'src/mail/dto/send-email.dto';
import { AuthService } from 'src/auth/auth.service';
import { decrypt } from '../crypto/crypto.util';
import { Correo, Status } from '../typeorm/votacionesPrueba.entity';
dotenv.config();
@Injectable()
export class MailService {
private transporter;
constructor(
private readonly sistemaService: SistemaService,
private readonly authService: AuthService,
@InjectRepository(Correo)
private readonly correoRepository: Repository<Correo>,
@InjectRepository(Status)
private readonly statusRepository: Repository<Status>,
@InjectRepository(Sistema)
private readonly sistemaRepositosistemaRepositoryry: Repository<Sistema>,
) {
/* this.transporter = nodemailer.createTransport({
host: 'smtp-relay.gmail.com',
port: 587,
secure: false,
requireTLS: true,
auth: {
user: process.env.USER_GMAIL,
pass: process.env.PASS_GMAIL,
},
}); */
/*
this.transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', // o smtprelay.gmail.com (ver punto 2)
port: 587,
secure: false,
requireTLS: true, // TLS STARTTLS
auth: { user: process.env.USER_GMAIL, pass: process.env.PASS_GMAIL },
pool: true, // <‑‑ activa reuse
maxConnections: 1, // una sola conexión viva
maxMessages: 100, // reabrir después de 100 envíos
rateDelta: 2000, // ventana 1 s
rateLimit: 1 // máx. 5 mensajes/seg
});
@InjectRepository(Correo) private readonly correoRepository: Repository<Correo>,
@InjectRepository(Status) private readonly statusRepository: Repository<Status>,
) {
/* this.transporter = nodemailer.createTransport({
host: 'smtp-relay.gmail.com',
port: 587,
secure: false,
requireTLS: true,
auth: {
user: process.env.USER_GMAIL,
pass: process.env.PASS_GMAIL,
},
}); */
this.transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', // o smtprelay.gmail.com (ver punto 2)
port: 587,
secure: false,
requireTLS: true, // TLS STARTTLS
auth: { user: process.env.USER_GMAIL, pass: process.env.PASS_GMAIL },
pool: true, // <‑‑ activa reuse
maxConnections: 1, // una sola conexión viva
maxMessages: 100, // reabrir después de 100 envíos
rateDelta: 2000, // ventana 1 s
rateLimit: 1 // máx. 5 mensajes/seg
});
/* this.transporter = nodemailer.createTransport({
host: 'smtp-relay.gmail.com', // o smtprelay.gmail.com (ver punto 2)
port: 587,
secure: false,
requireTLS: true, // TLS STARTTLS
//auth: { user: process.env.USER_GMAIL, pass: process.env.PASS_GMAIL },
name:"acatlan.unam.mx",
pool: true, // <‑‑ activa reuse
maxConnections: 1, // una sola conexión viva
maxMessages: 100, // reabrir después de 100 envíos
rateDelta: 2000, // ventana 1 s
rateLimit: 1 // máx. 5 mensajes/seg
}); */
*/
/*
this.transporter = nodemailer.createTransport({
host: 'localhost',
@@ -55,252 +72,85 @@ export class MailService {
*/
}
async findAllBySistema(TOKEN: string, nombreSistema: string) {
if (!TOKEN) {
throw new UnauthorizedException('Header "Password" es requerido');
}
const sistem = await this.sistemaService.acceso(TOKEN);
if (!sistem) {
throw new UnauthorizedException('Password incorrecto');
}
const result = await this.correoRepository.find({
where: {
id_sistema: { nombre_sistema: nombreSistema },
},
relations: {
id_sistema: true,
id_status: true,
},
});
return result;
}
async sendMail(sendEmail: SendCorreoDto, token: string) {
let payload;
payload = await this.authService.verificarToken(token);
if (!payload || !payload.nombre) {
throw new UnauthorizedException('Token inválido o incompleto');
}
const sistem = await this.sistemaService.findByNombre(payload.nombre);
if (!sistem) {
throw new UnauthorizedException('sistema No encontrado');
}
const decryptedPassword = decrypt(
sistem.email_password_encrypted,
sistem.email_password_iv,
);
const decryptedToken = decrypt(
sistem.refreshToken_encrypted,
sistem.refreshToken_iv,
);
console.log('sistem', {
type: 'OAuth2',
user: sistem.email,
clientId: sistem.clientId,
clientSecret: decryptedPassword,
refreshToken: decryptedToken,
//accessToken: sistem.accessToken, // opcional
});
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: sistem.email,
clientId: sistem.clientId,
clientSecret: decryptedPassword,
refreshToken: decryptedToken,
//accessToken: sistem.accessToken, // opcional
},
pool: true,
maxConnections: 1,
maxMessages: 100,
rateDelta: 2000,
rateLimit: 1,
});
// Función para generar el reporte de correos enviados y fallidos
async sendMail(to: string, subject: string, text: string, html: string, fecha_recibido: Date, sistema, adjuntos?: any[] ) {
console.log("estos son los archivos adjuntos en el service",adjuntos);
const mailOptions = {
from: sistem.email,
to: sendEmail.to,
subject: sendEmail.subject,
text: sendEmail.text,
html: sendEmail.html,
from: process.env.USER_GMAIL,
to,
subject,
text,
html,
// OJO: nodemailer usa 'attachments', no 'adjuntos'
attachments: (adjuntos || []).map((adj) => {
// Asegúrate de no incluir 'data:image/png;base64,' en 'content'
let base64Clean = adj.content;
// Si viene con prefijo "data:image...", lo quitamos
if (base64Clean.startsWith('data:image/')) {
base64Clean = base64Clean.split('base64,')[1];
}
return {
filename: adj.filename || 'qr.png',
content: Buffer.from(base64Clean, adj.encoding || 'base64'),
cid: adj.cid || 'qrCode', // el mismo que uses en <img src="cid:..."/>
contentType: 'image/png' // recomendable para que sepa que es PNG
};
})
// OJO: nodemailer usa 'attachments', no 'adjuntos'
attachments: (sendEmail.adjuntos || []).map((adj) => {
// Asegúrate de no incluir 'data:image/png;base64,' en 'content'
let base64Clean = adj.content;
// Si viene con prefijo "data:image...", lo quitamos
if (base64Clean.startsWith('data:image/')) {
base64Clean = base64Clean.split('base64,')[1];
}
return {
filename: adj.filename || 'qr.png',
content: Buffer.from(base64Clean, adj.encoding || 'base64'),
cid: adj.cid || 'qrCode', // el mismo que uses en <img src="cid:..."/>
contentType: 'image/png', // recomendable para que sepa que es PNG
};
}),
};
let resMail = await transporter.sendMail(mailOptions);
if (!resMail) throw new Error('');
const statusTexto = resMail.accepted.length > 0 ? 'Enviado' : 'Fallido';
console.log(mailOptions);
let resMail = await this.transporter.sendMail(mailOptions);
console.log("este es el resultado del envio",resMail);
//console.log("este es el resultado del envio",resMail.accepted.length);
const statusTexto = resMail.accepted.length > 0 ? "Enviado" : "Fallido";
/*
let status = await this.statusRepository.findOne({ where: { status: statusTexto } });
let status = await this.statusRepository.findOne({
where: { status: statusTexto },
});
if (!status) {
status = this.statusRepository.create({ status: statusTexto });
await this.statusRepository.save(status);
}
const info = this.correoRepository.create({
const info= this.correoRepository.create({
id_status: status,
id_sistema: sistem,
fecha_recibido: sendEmail.fecha_recibido,
id_sistema: sistema,
fecha_recibido: fecha_recibido,
fecha_enviado: new Date(),
destinatario: sendEmail.to,
remitente: sistem.email,
});
destinatario: to,
remitente: process.env.USER_GMAIL,
if (!resMail) {
throw new Error('fallo');
}); */
if(!resMail){
throw new Error("fallo")
}
this.correoRepository.save(info);
//this.correoRepository.save(info);
return statusTexto;
return (statusTexto);
}
private async saveEmailStatus(
dto: SendCorreoDto,
sistem: any,
statusText: string,
) {
try {
// 1. Buscar el estado en la base de datos (Enviado o Fallido)
let status = await this.statusRepository.findOne({
where: { status: statusText },
});
// 2. Si el estado no existe en el catálogo, lo creamos para evitar errores de FK
if (!status) {
status = this.statusRepository.create({ status: statusText });
await this.statusRepository.save(status);
}
// 3. Crear el registro histórico del correo
const info = this.correoRepository.create({
id_status: status,
id_sistema: sistem,
fecha_recibido: dto.fecha_recibido || new Date(), // Fecha en que se solicitó
fecha_enviado: new Date(), // Fecha real de salida
destinatario: dto.to,
remitente: sistem.email,
});
// 4. Guardar el registro de forma asíncrona
return await this.correoRepository.save(info);
} catch (error) {
// Solo hacemos log del error para no detener el proceso masivo si falla el log en DB
console.error(
`Error crítico al guardar status en DB para ${dto.to}:`,
error.message,
);
}
}
async sendBulkMail(emailsData: SendCorreoDto[], token: string) {
// 1. Validación de acceso y obtención de credenciales (una sola vez)
const payload = await this.authService.verificarToken(token);
if (!payload || !payload.nombre)
throw new UnauthorizedException('Token inválido');
const sistem = await this.sistemaService.findByNombre(payload.nombre);
if (!sistem) throw new UnauthorizedException('Sistema no encontrado');
const decryptedPassword = decrypt(
sistem.email_password_encrypted,
sistem.email_password_iv,
);
const decryptedToken = decrypt(
sistem.refreshToken_encrypted,
sistem.refreshToken_iv,
);
// 2. Crear el transporter único con Pool
const transporter = nodemailer.createTransport({
service: 'gmail',
pool: true,
maxConnections: 5, // Procesará hasta 5 conexiones SMTP simultáneas
auth: {
type: 'OAuth2',
user: sistem.email,
clientId: sistem.clientId,
clientSecret: decryptedPassword,
refreshToken: decryptedToken,
},
});
const CHUNK_SIZE = 50; // Tamaño de cada lote
const results = { successful: 0, failed: 0 };
// 3. Proceso por Chunks (Lotes)
for (let i = 0; i < emailsData.length; i += CHUNK_SIZE) {
const chunk = emailsData.slice(i, i + CHUNK_SIZE);
// Procesamos el lote actual en paralelo para ganar velocidad
await Promise.all(
chunk.map(async (emailDto) => {
try {
const mailOptions = {
from: sistem.email,
to: emailDto.to,
subject: emailDto.subject,
html: emailDto.html,
attachments: (emailDto.adjuntos || []).map((adj) => ({
filename: adj.filename || 'adjunto.png',
content: Buffer.from(
adj.content.split('base64,')[1] || adj.content,
'base64',
),
cid: adj.cid,
contentType: adj.contentType || 'image/png',
})),
};
await transporter.sendMail(mailOptions);
await this.saveEmailStatus(emailDto, sistem, 'Enviado');
results.successful++;
} catch (error) {
console.error(
`Error en envío masivo a ${emailDto.to}:`,
error.message,
);
await this.saveEmailStatus(emailDto, sistem, 'Fallido');
results.failed++;
}
}),
);
console.log(
`Lote completado: ${i + chunk.length} de ${emailsData.length}`,
);
}
transporter.close();
return results;
}
}
+3 -6
View File
@@ -1,12 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { json } from 'express';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(json({ limit: '50mb' }));
app.enableCors();
@@ -20,11 +19,9 @@ async function bootstrap() {
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('documentation', app, document);
await app.listen(process.env.PORT ?? 3000);
console.log(
`Aplicación corriendo en: http://localhost:${process.env.PORT ?? 3000}`,
);
console.log(`Aplicación corriendo en: http://localhost:${process.env.PORT ?? 3000}`);
}
bootstrap();
-5
View File
@@ -1,5 +0,0 @@
// comprobar-sistema.dto.ts
export class ComprobarSistemaDto {
password: string;
}
+4 -25
View File
@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { IsString, IsOptional, IsBoolean, IsNotEmpty } from 'class-validator';
export class CreateSistemaDto {
@IsNotEmpty()
@@ -9,29 +9,8 @@ export class CreateSistemaDto {
@IsString()
ip:string;
//Password del sistema
@IsNotEmpty()
@IsString()
password: string;
@IsNotEmpty()
@IsString()
email: string;
//clientSecret
@IsNotEmpty()
@IsString()
email_password:string
@IsNotEmpty()
@IsString()
clientId:string
//TU_REFRESH_TOKEN
@IsNotEmpty()
@IsString()
refreshToken:string;
@IsNotEmpty()
@IsString()
password: string;
}
-34
View File
@@ -1,34 +0,0 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UpdateSistemaDto {
@IsOptional()
@IsString()
Nombre?: string;
@IsOptional()
@IsString()
ip?: string;
//Password del sistema
@IsNotEmpty()
@IsString()
token: string;
@IsOptional()
@IsString()
email?: string;
//clientSecret
@IsOptional()
@IsString()
email_password?: string;
@IsOptional()
@IsString()
clientId?: string;
//TU_REFRESH_TOKEN
@IsOptional()
@IsString()
refreshToken?: string;
}
+6 -42
View File
@@ -1,58 +1,22 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Query,
} from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SistemaService } from './sistema.service';
import { CreateSistemaDto } from './dto/create-sistema.dto';
import { ComprobarSistemaDto } from './dto/comprobar-sistema';
import {
ApiAccesoSistema,
ApiCreateSistema,
ApiGenerarToken,
ApiUpdateSistema,
} from './sistema.decorators';
import { UpdateSistemaDto } from './dto/sistema-update';
import { ApiCreateSistema, ApiGenerarToken } from './sistema.decorators';
@Controller('sistema')
export class SistemaController {
constructor(private readonly sistemaService: SistemaService) {}
@Get()
findAll() {
return this.sistemaService.findAll();
}
@Post()
@ApiCreateSistema()
create(@Body() createSistemaDto: CreateSistemaDto) {
create(@Body() createSistemaDto: CreateSistemaDto, password:string) {
return this.sistemaService.create(createSistemaDto);
}
@Patch('config')
@ApiUpdateSistema()
update2(@Body() updateSistemaDto: UpdateSistemaDto) {
return this.sistemaService.update(updateSistemaDto);
}
@Patch(':nombre_sistema')
@ApiGenerarToken()
update(
@Param('nombre_sistema') nombre_sistema: string,
@Body() comprobarSistemaDto: ComprobarSistemaDto,
) {
return this.sistemaService.Generar_Token(
nombre_sistema,
comprobarSistemaDto.password,
);
}
@ApiAccesoSistema()
@Get('acceso')
async acceso(@Query('token') token: string) {
return this.sistemaService.acceso(token);
update(@Param('nombre_sistema') nombre_sistema: string, @Body() password: string) {
return this.sistemaService.Generar_Token(nombre_sistema, password);
}
}
+18 -127
View File
@@ -1,115 +1,28 @@
import { applyDecorators } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
export const ApiUpdateSistema = () => {
return applyDecorators(
ApiOperation({
summary: 'Actualizar configuración del sistema',
description: `
Este endpoint permite **actualizar parcialmente** la configuración de un sistema existente.
🔐 **Requiere token del sistema** para autorizar la operación.
Se pueden actualizar uno o más de los siguientes campos:
- Nombre del sistema
- IP
- Credenciales de correo
- Credenciales OAuth (clientId, refreshToken)
⚠️ Solo se actualizarán los campos enviados en el cuerpo.
`,
}),
ApiBody({
description: 'Datos a actualizar del sistema',
schema: {
type: 'object',
properties: {
Nombre: {
type: 'string',
example: 'Sistema de Notificaciones',
description: 'Nuevo nombre del sistema',
},
ip: {
type: 'string',
example: '192.168.0.20',
description: 'Nueva dirección IP autorizada',
},
token: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Token JWT del sistema (obligatorio)',
},
email: {
type: 'string',
format: 'email',
example: 'sistema@gmail.com',
description: 'Correo del sistema para envío de emails',
},
email_password: {
type: 'string',
example: 'app-password-123',
description: 'Contraseña o secreto del correo',
},
clientId: {
type: 'string',
example: '1234567890-abc.apps.googleusercontent.com',
description: 'Client ID de Google OAuth',
},
refreshToken: {
type: 'string',
example: '1//0gxxxxxxxxxxxxxxxx',
description: 'Refresh Token OAuth del correo',
},
},
required: ['token'],
},
}),
);
};
import { ApiBody, ApiOperation, ApiParam } from '@nestjs/swagger';
export const ApiCreateSistema = () => {
return applyDecorators(
ApiOperation({
summary: 'Registrar un nuevo sistema',
description: `Este endpoint permite registrar un sistema que podrá autenticarse y utilizar servicios protegidos.
Debe incluirse la contraseña maestra del administrador para autorizar la creación.
Además, se deben proporcionar las credenciales del correo que el sistema usará para enviar emails.`,
description: `Este endpoint permite dar de alta un sistema para poder utilizar funcionalidades autenticadas mediante contraseña.
Se debe proporcionar:
- **Nombre**: Nombre único del sistema.
- **ip**: Dirección IP desde la cual se harán peticiones.
- **password**: Contraseña segura que el sistema usará para autenticarse.`,
}),
ApiBody({
description: 'Datos del sistema a registrar',
schema: {
type: 'object',
properties: {
Nombre: {
type: 'string',
example: 'Sistema de Notificaciones',
description: 'Nombre único del sistema a registrar',
},
ip: {
type: 'string',
example: '192.168.0.10',
description: 'Dirección IP desde donde se harán las peticiones',
},
email: {
type: 'string',
format: 'email',
example: 'sistema@gmail.com',
description: 'Correo electrónico del sistema para enviar emails',
},
email_password: {
type: 'string',
example: 'contraseña-de-aplicacion',
description: 'Contraseña de aplicación del correo proporcionado',
},
password: {
type: 'string',
example: 'clave-admin-secreta',
description: 'Contraseña maestra para autorizar el registro',
},
Nombre: { type: 'string', example: 'Sistema de Reportes' },
ip: { type: 'string', example: '192.168.1.100' },
password: { type: 'string', example: 'clave-segura-123' },
},
required: ['Nombre', 'ip', 'email', 'email_password', 'password'],
required: ['Nombre', 'ip', 'password'],
},
}),
})
);
};
@@ -117,43 +30,21 @@ export const ApiGenerarToken = () => {
return applyDecorators(
ApiOperation({
summary: 'Generar token JWT para un sistema',
description: `Este endpoint permite generar un token de autenticación JWT para un sistema previamente registrado.
Se debe enviar el nombre del sistema como parámetro de ruta y su contraseña de registro en el cuerpo.`,
description: `Este endpoint permite generar un token de acceso válido para el sistema registrado.
Se debe enviar el nombre del sistema como parámetro de ruta y la contraseña como cuerpo.`,
}),
ApiParam({
name: 'nombre_sistema',
required: true,
description: 'Nombre exacto del sistema registrado',
example: 'Sistema de Notificaciones',
example: 'Sistema de Reportes',
}),
ApiBody({
description: 'Contraseña del sistema para verificar identidad',
description: 'Contraseña del sistema',
schema: {
type: 'object',
properties: {
password: {
type: 'string',
example: 'clave-admin-secreta',
description: 'Contraseña de autenticación del sistema',
},
},
required: ['password'],
type: 'string',
example: 'clave-segura-123',
},
}),
);
};
export const ApiAccesoSistema = () => {
return applyDecorators(
ApiOperation({
summary: 'Verificar acceso del sistema mediante token',
description: `Este endpoint permite verificar si un token JWT enviado por un sistema es válido y tiene acceso.`,
}),
ApiQuery({
name: 'token',
required: true,
description: 'Token JWT del sistema',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
}),
})
);
};
+3 -5
View File
@@ -2,17 +2,15 @@ import { Module } from '@nestjs/common';
import { SistemaService } from './sistema.service';
import { SistemaController } from './sistema.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Sistema } from 'src/typeorm/entidades';
import { AuthModule } from 'src/auth/auth.module';
import { Sistema } from 'src/typeorm/votacionesPrueba.entity';
@Module({
controllers: [SistemaController],
providers: [SistemaService],
imports:[
TypeOrmModule.forFeature([
Sistema]),
AuthModule
Sistema])
],
exports:[SistemaModule,SistemaService]
exports:[SistemaModule]
})
export class SistemaModule {}
+30 -141
View File
@@ -1,164 +1,53 @@
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { CreateSistemaDto } from './dto/create-sistema.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Sistema } from 'src/typeorm/entidades';
import { Sistema } from 'src/typeorm/votacionesPrueba.entity';
import { Repository } from 'typeorm';
import { AuthService } from 'src/auth/auth.service';
import { encrypt } from 'src/crypto/crypto.util';
import { UpdateSistemaDto } from './dto/sistema-update';
import { randomBytes } from 'crypto';
@Injectable()
export class SistemaService {
constructor(
@InjectRepository(Sistema)
private readonly sistemaRepository: Repository<Sistema>,
private readonly authService: AuthService,
) {}
@InjectRepository(Sistema) private readonly sistemaRepository: Repository<Sistema>
async findAll() {
const sistemas = await this.sistemaRepository.find();
if (!sistemas || sistemas.length === 0) {
throw new NotFoundException('No hay sistemas registrados');
}
return sistemas;
){}
generarPassword(longitud = 12): string {
return randomBytes(longitud)
.toString('base64')
.slice(0, longitud)
.replace(/[+/=]/g, () => String.fromCharCode(33 + Math.floor(Math.random() * 94))); // reemplaza caracteres raros
}
async Generar_Token(nombreSistema: string, adminPassword: string) {
if (adminPassword !== process.env.ADMIN_PASSWORD) {
throw new UnauthorizedException('Password erróneo');
async Generar_Token(nombreSistema:string,adminPasword:string) {
if(adminPasword!==process.env.ADMIN_PASSWORD){
throw new UnauthorizedException('Password erroneo');
}
const sistema = await this.sistemaRepository.findOneBy({
nombre_sistema: nombreSistema,
});
if (!sistema) {
throw new NotFoundException('Sistema no encontrado');
const sistema = await this.sistemaRepository.findOneBy({nombre_sistema:nombreSistema})
if(!sistema){
throw new NotFoundException('Sistema no encontrado')
}
sistema.token=this.generarPassword()
const token = this.authService.generarToken({
sistemaId: sistema.id_sistema,
nombre: sistema.nombre_sistema,
ip: sistema.ip,
});
return token;
}
async acceso(token: string) {
const decoded = await this.authService.verificarToken(token);
if (!decoded) {
throw new UnauthorizedException('Token inválido');
}
const sistema = await this.sistemaRepository.findOneBy({
id_sistema: decoded.sistemaId,
});
if (!sistema) {
throw new UnauthorizedException('Sistema no encontrado');
}
return true;
this.sistemaRepository.save(sistema)
return sistema.token;
}
async create(createSistemaDto: CreateSistemaDto) {
if (createSistemaDto.password !== process.env.ADMIN_PASSWORD) {
throw new UnauthorizedException('Password erróneo');
if(createSistemaDto.password!==process.env.ADMIN_PASSWORD){
throw new UnauthorizedException('Password erroneo');
}
const { encryptedData: encryptedData_token, iv: encryptedData_iv } =
encrypt(createSistemaDto.refreshToken);
const { encryptedData: encrypted_password, iv: iv_password } = encrypt(
createSistemaDto.email_password,
);
const sistema = this.sistemaRepository.create({
nombre_sistema: createSistemaDto.Nombre,
ip: createSistemaDto.ip,
email: createSistemaDto.email,
email_password_encrypted: encrypted_password,
email_password_iv: iv_password,
clientId: createSistemaDto.clientId,
refreshToken_encrypted: encryptedData_token,
refreshToken_iv: encryptedData_iv,
const sistem = this.sistemaRepository.create({
nombre_sistema:createSistemaDto.Nombre,
ip:createSistemaDto.ip,
});
await this.sistemaRepository.save(sistema);
await this.sistemaRepository.save(sistem)
return this.Generar_Token(
createSistemaDto.Nombre,
createSistemaDto.password,
);
return this.Generar_Token(createSistemaDto.Nombre,createSistemaDto.password);
}
async update(updateSistemaDto: UpdateSistemaDto) {
const decoded = await this.authService.verificarToken(
updateSistemaDto.token,
);
console.log('aqui');
if (!decoded) {
throw new UnauthorizedException('Token inválido');
}
let sistem = await this.sistemaRepository.findOneBy({
id_sistema: decoded.sistemaId,
});
if (!sistem) {
throw new UnauthorizedException('Sistema no encontrado');
}
let encryptedData_token: string | undefined;
let encryptedData_iv: string | undefined;
let encrypted_password: string | undefined;
let iv_password: string | undefined;
if (updateSistemaDto.refreshToken) {
({ encryptedData: encryptedData_token, iv: encryptedData_iv } = encrypt(
updateSistemaDto.refreshToken,
));
sistem.refreshToken_encrypted = encryptedData_token;
sistem.refreshToken_iv = encryptedData_iv;
}
if (updateSistemaDto.email_password) {
({ encryptedData: encrypted_password, iv: iv_password } = encrypt(
updateSistemaDto.email_password,
));
sistem.email_password_encrypted = encrypted_password;
sistem.email_password_iv = iv_password;
}
if (updateSistemaDto.Nombre) {
sistem.nombre_sistema = updateSistemaDto.Nombre;
}
if (updateSistemaDto.ip) {
sistem.ip = updateSistemaDto.ip;
}
if (updateSistemaDto.email) {
sistem.email = updateSistemaDto.email;
}
if (updateSistemaDto.clientId) {
sistem.clientId = updateSistemaDto.clientId;
}
await this.sistemaRepository.save(sistem);
return 'se guardo correctamente';
}
async findByNombre(sistema: string) {
const sistem = this.sistemaRepository.findOne({
where: { nombre_sistema: sistema },
});
if (!sistem) {
throw new UnauthorizedException('Password erróneo');
}
return sistem;
}
}
@@ -6,33 +6,14 @@ export class Sistema {
@PrimaryGeneratedColumn()
id_sistema: number;
@Column({ type: 'varchar', length: 50, nullable: false })
@Column({ type: 'varchar', length: 255, nullable: true })
token: string;
@Column({ type: 'varchar', length: 50 })
nombre_sistema: string;
@Column({ type: 'varchar', length: 50,nullable: false })
ip:string;
@Column({nullable: false})
email: string;
//clientSecret encriptada
@Column({nullable: false})
email_password_encrypted: string;
@Column({nullable: false})
email_password_iv: string;
@Column({nullable: false})
clientId:string
//TU_REFRESH_TOKEN
@Column({nullable: false})
refreshToken_encrypted:string;
@Column({nullable: false})
refreshToken_iv: string;
@Column({ type: 'varchar', length: 50 })
ip:string
@OneToMany(() => Correo, (correo) => correo.id_sistema)
correos: Correo[];