diff --git a/api-v3/src/database/migrations/1790049600000-phase-f4-smtp-superadmin.ts b/api-v3/src/database/migrations/1790049600000-phase-f4-smtp-superadmin.ts new file mode 100644 index 0000000..fa071a8 --- /dev/null +++ b/api-v3/src/database/migrations/1790049600000-phase-f4-smtp-superadmin.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseF4SmtpSuperadmin1790049600000 implements MigrationInterface { + name = 'PhaseF4SmtpSuperadmin1790049600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE smtp_settings ( + id smallint PRIMARY KEY, + enabled boolean NOT NULL DEFAULT false, + host varchar(255), + port integer, + security_mode varchar(20), + username varchar(255), + password_encrypted text, + from_name varchar(160), + from_email varchar(255), + reply_to varchar(255), + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_smtp_settings_singleton CHECK (id = 1), + CONSTRAINT chk_smtp_settings_port CHECK (port IS NULL OR port BETWEEN 1 AND 65535), + CONSTRAINT chk_smtp_settings_security CHECK ( + security_mode IS NULL OR security_mode IN ('TLS','STARTTLS') + ), + CONSTRAINT fk_smtp_settings_updated_by + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + INSERT INTO smtp_settings (id, enabled) + VALUES (1, false) + `); + await queryRunner.query(` + INSERT INTO permissions (code, description) + VALUES ('system_mail.manage', 'Configurar la salida SMTP del sistema y probar el transporte') + ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description + `); + await queryRunner.query(` + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id + FROM roles role + CROSS JOIN permissions permission + WHERE role.code = 'admin' + AND permission.code = 'system_mail.manage' + ON CONFLICT DO NOTHING + `); + + await queryRunner.query(` + UPDATE inspection_document_deliveries delivery + SET recipient_kind = 'INSPECTOR', + recipient_user_id = visit.lead_inspector_user_id, + recipient_key = visit.lead_inspector_user_id, + recipient_email = inspector.email, + status = CASE WHEN inspector.email IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + INNER JOIN users inspector ON inspector.id = visit.lead_inspector_user_id + WHERE delivery.act_id = act.id + AND delivery.document_kind = 'REPORT_WORD' + AND delivery.recipient_kind = 'DIRECTOR' + AND delivery.status <> 'SENT' + AND NOT EXISTS ( + SELECT 1 + FROM inspection_document_deliveries existing + WHERE existing.act_id = delivery.act_id + AND existing.document_kind = 'REPORT_WORD' + AND existing.recipient_kind = 'INSPECTOR' + AND existing.recipient_key = visit.lead_inspector_user_id + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE code = 'system_mail.manage' + ) + `); + await queryRunner.query(`DELETE FROM permissions WHERE code = 'system_mail.manage'`); + await queryRunner.query(`DROP TABLE IF EXISTS smtp_settings`); + } +} diff --git a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts index 3ed84a0..b5be54d 100644 --- a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts +++ b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts @@ -1,2 +1,12 @@ -import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator'; -export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; } +import { Transform } from 'class-transformer'; +import { IsEmail, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateDocumentDeliverySettingsDto { + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, + ) + @IsEmail() + @MaxLength(320) + officeEmail?: string | null; +} diff --git a/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts new file mode 100644 index 0000000..0b3d821 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts @@ -0,0 +1,79 @@ +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export enum SmtpSecurityModeDto { + TLS = 'TLS', + STARTTLS = 'STARTTLS', +} + +export class UpdateSmtpSettingsDto { + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + enabled!: boolean; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(255) + host!: string; + + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(65535) + port!: number; + + @IsEnum(SmtpSecurityModeDto) + securityMode!: SmtpSecurityModeDto; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null)) + @IsString() + @MaxLength(255) + username?: string | null; + + @IsOptional() + @IsString() + @MaxLength(2048) + password?: string; + + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + clearPassword?: boolean; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(1) + @MaxLength(160) + fromName!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEmail() + @MaxLength(255) + fromEmail!: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null)) + @IsEmail() + @MaxLength(255) + replyTo?: string | null; +} + +export class TestSmtpSettingsDto { + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value)) + @IsEmail() + @MaxLength(255) + to!: string; +} diff --git a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts index d855648..4a2f726 100644 --- a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts +++ b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts @@ -1,5 +1,4 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; @@ -10,7 +9,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; -export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; +export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR'; export interface DeliveryRow { id: string; @@ -35,26 +34,18 @@ export class InspectionDocumentDeliveryService { private readonly word: InspectionReportWordService, private readonly smtp: SmtpDeliveryService, private readonly audit: AuditService, - private readonly config: ConfigService, ) {} async settings() { const [row] = await this.dataSource.query(` - SELECT - office_email AS "officeEmail", - director_email AS "directorEmail", - updated_at AS "updatedAt" + SELECT office_email AS "officeEmail", updated_at AS "updatedAt" FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ - officeEmail: string | null; - directorEmail: string | null; - updatedAt: Date; - }>; + WHERE id = 1 + `) as Array<{ officeEmail: string | null; updatedAt: Date }>; return { ...row, smtpConfigured: this.smtp.configured(), - mailFrom: this.config.get('MAIL_FROM') ?? null, + smtpSource: this.smtp.activeSource(), }; } @@ -66,9 +57,11 @@ export class InspectionDocumentDeliveryService { const before = await this.settings(); await this.dataSource.query(` UPDATE institutional_delivery_settings - SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP - WHERE id=1 - `, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]); + SET office_email = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, [dto.officeEmail ?? null, principal.userId]); const after = await this.settings(); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService { async list() { const data = await this.dataSource.query(` SELECT - d.id, - d.act_id AS "actId", - d.report_id AS "reportId", - d.document_kind AS "documentKind", - d.recipient_kind AS "recipientKind", - d.recipient_asset_id AS "recipientAssetId", - d.recipient_user_id AS "recipientUserId", - d.recipient_email AS "recipientEmail", - d.status, - d.attempts, - d.last_attempt_at AS "lastAttemptAt", - d.sent_at AS "sentAt", - d.provider_message_id AS "providerMessageId", - d.last_error AS "lastError", - d.created_at AS "createdAt", - a.code AS "actCode", - r.code AS "reportCode", + delivery.id, + delivery.act_id AS "actId", + delivery.report_id AS "reportId", + delivery.document_kind AS "documentKind", + delivery.recipient_kind AS "recipientKind", + delivery.recipient_asset_id AS "recipientAssetId", + delivery.recipient_user_id AS "recipientUserId", + delivery.recipient_email AS "recipientEmail", + delivery.status, + delivery.attempts, + delivery.last_attempt_at AS "lastAttemptAt", + delivery.sent_at AS "sentAt", + delivery.provider_message_id AS "providerMessageId", + delivery.last_error AS "lastError", + delivery.created_at AS "createdAt", + act.code AS "actCode", + report.code AS "reportCode", recipient.name AS "recipientAssetName", CASE WHEN recipient_user.id IS NULL THEN NULL ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name)) END AS "recipientUserName" - FROM inspection_document_deliveries d - JOIN inspection_acts a ON a.id=d.act_id - LEFT JOIN inspection_reports r ON r.id=d.report_id - LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id - LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id - ORDER BY d.created_at DESC + FROM inspection_document_deliveries delivery + JOIN inspection_acts act ON act.id = delivery.act_id + LEFT JOIN inspection_reports report ON report.id = delivery.report_id + LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id + LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id + ORDER BY delivery.created_at DESC LIMIT 200 `); return { data }; @@ -120,13 +113,14 @@ export class InspectionDocumentDeliveryService { async dispatchForAct(actId: string): Promise { await this.pdf.ensure(actId).catch(() => undefined); const [report] = await this.dataSource.query( - `SELECT id FROM inspection_reports WHERE act_id=$1`, + `SELECT id FROM inspection_reports WHERE act_id = $1`, [actId], ) as Array<{ id: string }>; if (report) await this.word.ensure(report.id); await this.ensureRows(actId, report?.id ?? null); - const rows = await this.rowsForAct(actId); - for (const row of rows) await this.attempt(row).catch(() => undefined); + for (const row of await this.rowsForAct(actId)) { + await this.attempt(row).catch(() => undefined); + } } async retry( @@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService { const rows = await this.dataSource.query(` SELECT id FROM inspection_document_deliveries - WHERE status<>'SENT' + WHERE status <> 'SENT' ORDER BY created_at ASC LIMIT 100 `) as Array<{ id: string }>; - for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined); + for (const item of rows) { + await this.retry(item.id, principal, request).catch(() => undefined); + } return { processed: rows.length }; } private async ensureRows(actId: string, reportId: string | null) { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" + SELECT office_email AS "officeEmail" FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; + WHERE id = 1 + `) as Array<{ officeEmail: string | null }>; const companies = await this.dataSource.query(` - SELECT DISTINCT company.id,profile.notification_email AS email + SELECT DISTINCT company.id, profile.notification_email AS email FROM inspection_act_assets link - JOIN assets asset ON asset.id=link.asset_id - JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id - JOIN assets company ON company.id=COALESCE( + JOIN assets asset ON asset.id = link.asset_id + JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + JOIN assets company ON company.id = COALESCE( asset.operator_company_id, - CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END + CASE WHEN asset_type.operational_role = 'COMPANY' THEN asset.id END ) - LEFT JOIN organization_profiles profile ON profile.asset_id=company.id - WHERE link.act_id=$1 AND link.included=true + LEFT JOIN organization_profiles profile ON profile.asset_id = company.id + WHERE link.act_id = $1 AND link.included = true `, [actId]) as Array<{ id: string; email: string | null }>; const [inspector] = await this.dataSource.query(` - SELECT - inspector.id, - inspector.email + SELECT inspector.id, inspector.email FROM inspection_acts act - JOIN inspection_visits visit ON visit.id=act.visit_id - JOIN users inspector ON inspector.id=visit.lead_inspector_user_id - WHERE act.id=$1 + JOIN inspection_visits visit ON visit.id = act.visit_id + JOIN users inspector ON inspector.id = visit.lead_inspector_user_id + WHERE act.id = $1 `, [actId]) as Array<{ id: string; email: string | null }>; for (const company of companies) { @@ -229,19 +223,18 @@ export class InspectionDocumentDeliveryService { recipientKey: inspector.id, recipientEmail: inspector.email, }); - } - - if (reportId) { - await this.upsertRow({ - actId, - reportId, - documentKind: 'REPORT_WORD', - recipientKind: 'DIRECTOR', - recipientAssetId: null, - recipientUserId: null, - recipientKey: '00000000-0000-0000-0000-000000000000', - recipientEmail: settings?.directorEmail ?? null, - }); + if (reportId) { + await this.upsertRow({ + actId, + reportId, + documentKind: 'REPORT_WORD', + recipientKind: 'INSPECTOR', + recipientAssetId: null, + recipientUserId: inspector.id, + recipientKey: inspector.id, + recipientEmail: inspector.email, + }); + } } } @@ -249,7 +242,7 @@ export class InspectionDocumentDeliveryService { actId: string; reportId: string | null; documentKind: 'ACT_PDF' | 'REPORT_WORD'; - recipientKind: DeliveryRecipientKind; + recipientKind: Exclude; recipientAssetId: string | null; recipientUserId: string | null; recipientKey: string; @@ -258,23 +251,23 @@ export class InspectionDocumentDeliveryService { const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT'; await this.dataSource.query(` INSERT INTO inspection_document_deliveries ( - act_id,report_id,document_kind,recipient_kind, - recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status + act_id, report_id, document_kind, recipient_kind, + recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status ) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9) - ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET - report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id), - recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id), - recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), - recipient_email=CASE - WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email + ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET + report_id = COALESCE(EXCLUDED.report_id, inspection_document_deliveries.report_id), + recipient_asset_id = COALESCE(EXCLUDED.recipient_asset_id, inspection_document_deliveries.recipient_asset_id), + recipient_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id), + recipient_email = CASE + WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email ELSE EXCLUDED.recipient_email END, - status=CASE - WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' + status = CASE + WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' ELSE inspection_document_deliveries.status END, - updated_at=CURRENT_TIMESTAMP + updated_at = CURRENT_TIMESTAMP `, [ input.actId, input.reportId, @@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService { private async rowsForAct(actId: string): Promise { return this.dataSource.query(` SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", - d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", - d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", - d.recipient_email AS "recipientEmail",d.status,d.attempts, - a.code AS "actCode",r.code AS "reportCode" - FROM inspection_document_deliveries d - JOIN inspection_acts a ON a.id=d.act_id - LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.act_id=$1 - ORDER BY d.created_at + delivery.id, + delivery.act_id AS "actId", + delivery.report_id AS "reportId", + delivery.document_kind AS "documentKind", + delivery.recipient_kind AS "recipientKind", + delivery.recipient_asset_id AS "recipientAssetId", + delivery.recipient_user_id AS "recipientUserId", + delivery.recipient_email AS "recipientEmail", + delivery.status, + delivery.attempts, + act.code AS "actCode", + report.code AS "reportCode" + FROM inspection_document_deliveries delivery + JOIN inspection_acts act ON act.id = delivery.act_id + LEFT JOIN inspection_reports report ON report.id = delivery.report_id + WHERE delivery.act_id = $1 + ORDER BY delivery.created_at `, [actId]) as Promise; } private async load(id: string): Promise { const [row] = await this.dataSource.query(` SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", - d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", - d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", - d.recipient_email AS "recipientEmail",d.status,d.attempts, - a.code AS "actCode",r.code AS "reportCode" - FROM inspection_document_deliveries d - JOIN inspection_acts a ON a.id=d.act_id - LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.id=$1 + delivery.id, + delivery.act_id AS "actId", + delivery.report_id AS "reportId", + delivery.document_kind AS "documentKind", + delivery.recipient_kind AS "recipientKind", + delivery.recipient_asset_id AS "recipientAssetId", + delivery.recipient_user_id AS "recipientUserId", + delivery.recipient_email AS "recipientEmail", + delivery.status, + delivery.attempts, + act.code AS "actCode", + report.code AS "reportCode" + FROM inspection_document_deliveries delivery + JOIN inspection_acts act ON act.id = delivery.act_id + LEFT JOIN inspection_reports report ON report.id = delivery.report_id + WHERE delivery.id = $1 `, [id]) as DeliveryRow[]; if (!row) { throw new NotFoundException({ @@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService { const [company] = await this.dataSource.query(` SELECT notification_email AS email FROM organization_profiles - WHERE asset_id=$1 + WHERE asset_id = $1 `, [row.recipientAssetId]) as Array<{ email: string | null }>; email = company?.email ?? null; } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { const [inspector] = await this.dataSource.query(` SELECT email FROM users - WHERE id=$1 AND is_active=true + WHERE id = $1 AND is_active = true `, [row.recipientUserId]) as Array<{ email: string | null }>; email = inspector?.email ?? null; - } else { + } else if (row.recipientKind === 'OFFICE') { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" + SELECT office_email AS "officeEmail" FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; - email = row.recipientKind === 'OFFICE' - ? settings?.officeEmail ?? null - : settings?.directorEmail ?? null; + WHERE id = 1 + `) as Array<{ officeEmail: string | null }>; + email = settings?.officeEmail ?? null; + } else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') { + const [inspector] = await this.dataSource.query(` + SELECT user_account.email + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id = act.visit_id + JOIN users user_account ON user_account.id = visit.lead_inspector_user_id + WHERE act.id = $1 + `, [row.actId]) as Array<{ email: string | null }>; + email = inspector?.email ?? null; } await this.dataSource.query(` UPDATE inspection_document_deliveries - SET recipient_email=$2, - status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, - last_error=NULL, - updated_at=CURRENT_TIMESTAMP - WHERE id=$1 AND status<>'SENT' + SET recipient_email = $2, + status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND status <> 'SENT' `, [row.id, email]); } @@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService { await this.dataSource.query(` UPDATE inspection_document_deliveries - SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING', - last_error=NULL,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 + SET attempts = attempts + 1, + last_attempt_at = CURRENT_TIMESTAMP, + status = 'PENDING', + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 `, [row.id]); try { @@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService { ? `Acta ${row.actCode}` : `Informe ${row.reportCode ?? ''}`; const text = row.documentKind === 'REPORT_WORD' - ? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` + ? `Se adjunta el Informe Word automático ${row.reportCode ?? ''} para revisión y edición del inspector responsable antes de su carga en GEDO.` : row.recipientKind === 'INSPECTOR' - ? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` - : `Se adjunta el acta cerrada e inmutable ${row.actCode}.`; + ? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.` + : `Se adjunta el Acta ${row.actCode}.`; const sent = await this.smtp.send({ to: row.recipientEmail, subject: `DH Inspección · ${label}`, @@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService { }); await this.dataSource.query(` UPDATE inspection_document_deliveries - SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, - last_error=NULL,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 + SET status = 'SENT', + sent_at = CURRENT_TIMESTAMP, + provider_message_id = $2, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 `, [row.id, sent.messageId]); await this.audit.record({ action: AuditAction.DOCUMENT_DELIVERY_SENT, @@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService { private async setStatus(id: string, status: string, error: string) { await this.dataSource.query(` UPDATE inspection_document_deliveries - SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 + SET status = $2, + last_error = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 `, [id, status, error.slice(0, 500)]); } } diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index c9967b2..60a6ca1 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -13,6 +13,8 @@ import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; +import { SmtpSettingsController } from './smtp-settings.controller'; +import { SmtpSettingsService } from './smtp-settings.service'; @Module({ imports: [AuditModule, InspectionDeadlinesModule], @@ -22,6 +24,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionReportDossierController, InspectionReportFollowUpFileController, DocumentDeliveryController, + SmtpSettingsController, ], providers: [ InspectionReportsService, @@ -30,6 +33,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionActPdfService, InspectionDocumentDeliveryService, SmtpDeliveryService, + SmtpSettingsService, ], exports: [InspectionReportsService], }) diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index bce88d6..8cdb39d 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -1,37 +1,328 @@ +import { 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 { Injectable, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { decryptSmtpSecret } from './smtp-settings-crypto'; -interface MailAttachment { filename:string; mimeType:string; content:Buffer; } -interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } -interface Reply { code:number; text:string; } +interface MailAttachment { + filename: string; + mimeType: string; + content: Buffer; +} + +interface MailInput { + to: string; + subject: string; + text: string; + attachment?: MailAttachment; +} + +interface Reply { + code: number; + text: string; +} + +interface RuntimeSmtpConfig { + source: 'DATABASE' | 'ENV'; + host: string; + port: number; + securityMode: 'TLS' | 'STARTTLS'; + user: string; + pass: string; + fromHeader: string; + 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();} - current(){return this.socket;} + 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 = []; + this.waiters.shift()?.(reply); + } + } + } + + reply(): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.socket.off('error', onError); + reject(error); + }; + 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){ 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 encodedSubject(value: string) { + return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`; +} + +function escapeHeader(value: string) { + return value.replace(/[\r\n]/g, ' ').replaceAll('"', "'"); +} + +function mime(input: MailInput, config: RuntimeSmtpConfig) { + const headers = [ + `From: ${config.fromHeader}`, + `To: ${input.to}`, + `Subject: ${encodedSubject(input.subject)}`, + ...(config.replyTo ? [`Reply-To: ${config.replyTo}`] : []), + 'MIME-Version: 1.0', + ]; + if (!input.attachment) { + return [ + ...headers, + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + Buffer.from(input.text, 'utf8').toString('base64'), + '', + ].join('\r\n').replace(/^\./gm, '..'); + } + const boundary = `dh-${randomUUID()}`; + const filename = escapeHeader(input.attachment.filename); + return [ + ...headers, + `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="${filename}"`, + 'Content-Transfer-Encoding: base64', + `Content-Disposition: attachment; filename="${filename}"`, + '', + input.attachment.content.toString('base64').replace(/(.{76})/g, '$1\r\n'), + `--${boundary}--`, + '', + ].join('\r\n').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()}; } +export class SmtpDeliveryService implements OnModuleInit { + private runtime: RuntimeSmtpConfig | null; + private runtimeError: string | null = null; + + constructor( + private readonly dataSource: DataSource, + private readonly config: ConfigService, + ) { + this.runtime = this.environmentConfig(); + } + + async onModuleInit(): Promise { + await this.reload().catch((error) => { + this.runtime = null; + this.runtimeError = error instanceof Error ? error.message : 'SMTP configuration error'; + }); + } + + configured() { + return Boolean(this.runtime); + } + + activeSource() { + return this.runtime?.source ?? null; + } + + async reload(): Promise { + const [row] = (await this.dataSource.query(` + SELECT + enabled, + host, + port, + security_mode AS "securityMode", + username, + password_encrypted AS "passwordEncrypted", + from_name AS "fromName", + from_email AS "fromEmail", + reply_to AS "replyTo" + FROM smtp_settings + WHERE id = 1 + `).catch(() => [])) as Array<{ + enabled: boolean; + host: string | null; + port: number | null; + securityMode: 'TLS' | 'STARTTLS' | null; + username: string | null; + passwordEncrypted: string | null; + fromName: string | null; + fromEmail: string | null; + replyTo: string | null; + }>; + + if (!row?.enabled) { + this.runtime = this.environmentConfig(); + this.runtimeError = null; + return; + } + if (!row.host || !row.port || !row.securityMode || !row.fromName || !row.fromEmail) { + this.runtime = null; + this.runtimeError = 'La configuración SMTP del Superadmin está incompleta'; + return; + } + let pass = ''; + if (row.passwordEncrypted) { + const key = this.config.get('SMTP_SETTINGS_ENCRYPTION_KEY'); + if (!key) { + this.runtime = null; + this.runtimeError = 'Falta SMTP_SETTINGS_ENCRYPTION_KEY para descifrar la contraseña SMTP'; + return; + } + pass = decryptSmtpSecret(row.passwordEncrypted, key); + } + this.runtime = { + source: 'DATABASE', + host: row.host, + port: Number(row.port), + securityMode: row.securityMode, + user: row.username ?? '', + pass, + fromHeader: `"${escapeHeader(row.fromName)}" <${row.fromEmail}>`, + fromEmail: row.fromEmail, + replyTo: row.replyTo, + }; + this.runtimeError = null; + } + + async send(input: MailInput): Promise<{ messageId: string }> { + if (!this.runtime) await this.reload(); + const config = this.runtime; + if (!config) throw new Error(this.runtimeError ?? 'SMTP no configurado'); + + const base = await new Promise((resolve, reject) => { + if (config.securityMode === 'TLS') { + const tls = connectTls({ + host: config.host, + port: config.port, + servername: config.host, + rejectUnauthorized: true, + }, () => resolve(tls)); + tls.once('error', reject); + } else { + const raw = connectNet({ host: config.host, port: config.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 (config.securityMode === 'STARTTLS') { + if (!/STARTTLS/i.test(ehlo.text)) throw new Error('El servidor SMTP no ofrece STARTTLS'); + await session.command('STARTTLS', 220); + const raw = session.detachForUpgrade() as Socket; + const upgraded = await new Promise((resolve, reject) => { + const tls = connectTls({ socket: raw, servername: config.host, rejectUnauthorized: true }, () => resolve(tls)); + tls.once('error', reject); + }); + session.replaceSocket(upgraded); + ehlo = await session.command('EHLO dh-inspeccion', 250); + } + if (config.user) { + if (/AUTH[^\n]*PLAIN/i.test(ehlo.text)) { + const token = Buffer.from(`\u0000${config.user}\u0000${config.pass}`, 'utf8').toString('base64'); + await session.command(`AUTH PLAIN ${token}`, 235); + } else { + await session.command('AUTH LOGIN', 334); + await session.command(Buffer.from(config.user).toString('base64'), 334); + await session.command(Buffer.from(config.pass).toString('base64'), 235); + } + } + await session.command(`MAIL FROM:<${config.fromEmail}>`, 250); + await session.command(`RCPT TO:<${input.to}>`, [250, 251]); + await session.command('DATA', 354); + session.write(`${mime(input, config)}\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 environmentConfig(): RuntimeSmtpConfig | null { + const host = this.config.get('SMTP_HOST'); + const from = this.config.get('MAIL_FROM'); + if (!host || !from) return null; + const match = from.match(/^(?:\s*"?([^"<]+)"?\s*)?<([^>]+)>\s*$/); + const fromEmail = (match?.[2] ?? from).trim(); + return { + source: 'ENV', + host, + port: Number(this.config.get('SMTP_PORT') ?? 587), + securityMode: String(this.config.get('SMTP_SECURE') ?? 'false').toLowerCase() === 'true' + ? 'TLS' + : 'STARTTLS', + user: this.config.get('SMTP_USER') ?? '', + pass: this.config.get('SMTP_PASS') ?? '', + fromHeader: from, + fromEmail, + replyTo: null, + }; + } } diff --git a/api-v3/src/inspection-reports/smtp-settings-crypto.ts b/api-v3/src/inspection-reports/smtp-settings-crypto.ts new file mode 100644 index 0000000..5d532ef --- /dev/null +++ b/api-v3/src/inspection-reports/smtp-settings-crypto.ts @@ -0,0 +1,38 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +function keyFromSecret(secret: string): Buffer { + if (secret.trim().length < 24) { + throw new Error('SMTP_SETTINGS_ENCRYPTION_KEY must contain at least 24 characters'); + } + return createHash('sha256').update(secret, 'utf8').digest(); +} + +export function encryptSmtpSecret(value: string, secret: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', keyFromSecret(secret), 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'), + ].join(':'); +} + +export function decryptSmtpSecret(value: string, secret: string): string { + const [version, ivEncoded, tagEncoded, encryptedEncoded] = value.split(':'); + if (version !== 'v1' || !ivEncoded || !tagEncoded || encryptedEncoded === undefined) { + throw new Error('Invalid encrypted SMTP password payload'); + } + const decipher = createDecipheriv( + 'aes-256-gcm', + keyFromSecret(secret), + Buffer.from(ivEncoded, 'base64'), + ); + decipher.setAuthTag(Buffer.from(tagEncoded, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedEncoded, 'base64')), + decipher.final(), + ]).toString('utf8'); +} diff --git a/api-v3/src/inspection-reports/smtp-settings.controller.ts b/api-v3/src/inspection-reports/smtp-settings.controller.ts new file mode 100644 index 0000000..6916e88 --- /dev/null +++ b/api-v3/src/inspection-reports/smtp-settings.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Get, Put, Post, Req } from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto'; +import { SmtpDeliveryService } from './smtp-delivery.service'; +import { SmtpSettingsService } from './smtp-settings.service'; + +@Controller('system-mail-settings') +export class SmtpSettingsController { + constructor( + private readonly settings: SmtpSettingsService, + private readonly smtp: SmtpDeliveryService, + ) {} + + @Get() + @RequirePermissions('system_mail.manage') + async get() { + return { + ...(await this.settings.getPublic()), + transportConfigured: this.smtp.configured(), + activeSource: this.smtp.activeSource(), + }; + } + + @Put() + @RequirePermissions('system_mail.manage') + async update( + @Body() dto: UpdateSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + const result = await this.settings.update(dto, principal, request); + await this.smtp.reload(); + return { + ...result, + transportConfigured: this.smtp.configured(), + activeSource: this.smtp.activeSource(), + }; + } + + @Post('test') + @RequirePermissions('system_mail.manage') + async test(@Body() dto: TestSmtpSettingsDto) { + const sent = await this.smtp.send({ + to: dto.to, + subject: 'DH Inspección · Prueba SMTP', + text: 'Este correo confirma que la salida SMTP configurada en Superadmin funciona correctamente.', + }); + return { sent: true, messageId: sent.messageId }; + } +} diff --git a/api-v3/src/inspection-reports/smtp-settings.service.ts b/api-v3/src/inspection-reports/smtp-settings.service.ts new file mode 100644 index 0000000..29af036 --- /dev/null +++ b/api-v3/src/inspection-reports/smtp-settings.service.ts @@ -0,0 +1,170 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import type { UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto'; +import { encryptSmtpSecret } from './smtp-settings-crypto'; + +interface SmtpSettingsRow { + enabled: boolean; + host: string | null; + port: number | null; + securityMode: string | null; + username: string | null; + passwordEncrypted: string | null; + fromName: string | null; + fromEmail: string | null; + replyTo: string | null; + updatedAt: Date; +} + +@Injectable() +export class SmtpSettingsService { + constructor( + private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly audit: AuditService, + ) {} + + async getPublic() { + const row = await this.load(); + return this.toPublic(row); + } + + async update( + dto: UpdateSmtpSettingsDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const encryptionKey = this.config.get('SMTP_SETTINGS_ENCRYPTION_KEY'); + if (dto.password !== undefined && !encryptionKey) { + throw new ConflictException({ + code: 'SMTP_SETTINGS_ENCRYPTION_KEY_MISSING', + message: 'Falta configurar la clave maestra del servidor para proteger la contraseña SMTP', + }); + } + return this.dataSource.transaction(async (manager) => { + const [current] = (await manager.query(` + SELECT + enabled, + host, + port, + security_mode AS "securityMode", + username, + password_encrypted AS "passwordEncrypted", + from_name AS "fromName", + from_email AS "fromEmail", + reply_to AS "replyTo", + updated_at AS "updatedAt" + FROM smtp_settings + WHERE id = 1 + FOR UPDATE + `)) as SmtpSettingsRow[]; + let passwordEncrypted = current?.passwordEncrypted ?? null; + if (dto.clearPassword) passwordEncrypted = null; + if (dto.password !== undefined) { + passwordEncrypted = encryptSmtpSecret(dto.password, encryptionKey!); + } + await manager.query(` + UPDATE smtp_settings + SET enabled = $1, + host = $2, + port = $3, + security_mode = $4, + username = $5, + password_encrypted = $6, + from_name = $7, + from_email = $8, + reply_to = $9, + updated_by = $10, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `, [ + dto.enabled, + dto.host, + dto.port, + dto.securityMode, + dto.username ?? null, + passwordEncrypted, + dto.fromName, + dto.fromEmail, + dto.replyTo ?? null, + principal.userId, + ]); + const [after] = (await manager.query(` + SELECT + enabled, + host, + port, + security_mode AS "securityMode", + username, + password_encrypted AS "passwordEncrypted", + from_name AS "fromName", + from_email AS "fromEmail", + reply_to AS "replyTo", + updated_at AS "updatedAt" + FROM smtp_settings + WHERE id = 1 + `)) as SmtpSettingsRow[]; + const beforePublic = current ? this.toPublic(current) : null; + const afterPublic = this.toPublic(after); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: 'SMTP_SETTINGS_UPDATED', + entityType: 'smtp_settings', + entityId: '1', + beforeData: beforePublic, + afterData: afterPublic, + metadata: { passwordChanged: dto.password !== undefined || Boolean(dto.clearPassword) }, + }, manager); + return afterPublic; + }); + } + + private async load(): Promise { + const [row] = (await this.dataSource.query(` + SELECT + enabled, + host, + port, + security_mode AS "securityMode", + username, + password_encrypted AS "passwordEncrypted", + from_name AS "fromName", + from_email AS "fromEmail", + reply_to AS "replyTo", + updated_at AS "updatedAt" + FROM smtp_settings + WHERE id = 1 + `)) as SmtpSettingsRow[]; + return row ?? { + enabled: false, + host: null, + port: null, + securityMode: null, + username: null, + passwordEncrypted: null, + fromName: null, + fromEmail: null, + replyTo: null, + updatedAt: new Date(0), + }; + } + + private toPublic(row: SmtpSettingsRow): Record { + return { + enabled: row.enabled, + host: row.host, + port: row.port, + securityMode: row.securityMode, + username: row.username, + passwordConfigured: Boolean(row.passwordEncrypted), + fromName: row.fromName, + fromEmail: row.fromEmail, + replyTo: row.replyTo, + updatedAt: row.updatedAt, + }; + } +} diff --git a/api-v3/test/unit/phase-f4-smtp-authorization.test.ts b/api-v3/test/unit/phase-f4-smtp-authorization.test.ts new file mode 100644 index 0000000..ea62ad9 --- /dev/null +++ b/api-v3/test/unit/phase-f4-smtp-authorization.test.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; +import { SmtpSettingsController } from '../../src/inspection-reports/smtp-settings.controller'; + +function permissions(method: string): string[] { + const handler = SmtpSettingsController.prototype[ + method as keyof SmtpSettingsController + ]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('F4 SMTP configuration is restricted to the programmer Superadmin permission', () => { + assert.deepEqual(permissions('get'), ['system_mail.manage']); + assert.deepEqual(permissions('update'), ['system_mail.manage']); + assert.deepEqual(permissions('test'), ['system_mail.manage']); +}); diff --git a/api-v3/test/unit/phase-f4-smtp-delivery.test.ts b/api-v3/test/unit/phase-f4-smtp-delivery.test.ts new file mode 100644 index 0000000..77c6dd2 --- /dev/null +++ b/api-v3/test/unit/phase-f4-smtp-delivery.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +test('F4 stores SMTP password encrypted and never exposes it through public settings', () => { + const crypto = read('src/inspection-reports/smtp-settings-crypto.ts'); + const settings = read('src/inspection-reports/smtp-settings.service.ts'); + assert.match(crypto, /aes-256-gcm/); + assert.match(settings, /passwordConfigured/); + assert.doesNotMatch(settings, /passwordEncrypted:\s*row\.passwordEncrypted/); +}); + +test('F4 sends the editable Report Word to the responsible Inspector instead of creating new Director deliveries', () => { + const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); + assert.match(delivery, /documentKind: 'REPORT_WORD',[\s\S]*recipientKind: 'INSPECTOR'/); + assert.doesNotMatch(delivery, /documentKind: 'REPORT_WORD',[\s\S]{0,180}recipientKind: 'DIRECTOR'/); + assert.match(delivery, /revisión y edición del inspector responsable antes de su carga en GEDO/); +}); + +test('F4 keeps ENV SMTP only as migration fallback and prioritizes enabled Superadmin DB settings', () => { + const smtp = read('src/inspection-reports/smtp-delivery.service.ts'); + assert.match(smtp, /source: 'DATABASE'/); + assert.match(smtp, /source: 'ENV'/); + assert.match(smtp, /FROM smtp_settings/); +});