F4.5 · SMTP Superadmin y Report Word al Inspector
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF4SmtpSuperadmin1790049600000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF4SmtpSuperadmin1790049600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,12 @@
|
|||||||
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
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 { 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
import { AuditService } from '../audit/audit.service';
|
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 { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
|
||||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
|
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
|
||||||
|
|
||||||
export interface DeliveryRow {
|
export interface DeliveryRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,26 +34,18 @@ export class InspectionDocumentDeliveryService {
|
|||||||
private readonly word: InspectionReportWordService,
|
private readonly word: InspectionReportWordService,
|
||||||
private readonly smtp: SmtpDeliveryService,
|
private readonly smtp: SmtpDeliveryService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly config: ConfigService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async settings() {
|
async settings() {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
|
||||||
office_email AS "officeEmail",
|
|
||||||
director_email AS "directorEmail",
|
|
||||||
updated_at AS "updatedAt"
|
|
||||||
FROM institutional_delivery_settings
|
FROM institutional_delivery_settings
|
||||||
WHERE id=1
|
WHERE id = 1
|
||||||
`) as Array<{
|
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||||
officeEmail: string | null;
|
|
||||||
directorEmail: string | null;
|
|
||||||
updatedAt: Date;
|
|
||||||
}>;
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
smtpConfigured: this.smtp.configured(),
|
smtpConfigured: this.smtp.configured(),
|
||||||
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
|
smtpSource: this.smtp.activeSource(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +57,11 @@ export class InspectionDocumentDeliveryService {
|
|||||||
const before = await this.settings();
|
const before = await this.settings();
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE institutional_delivery_settings
|
UPDATE institutional_delivery_settings
|
||||||
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
SET office_email = $1,
|
||||||
WHERE id=1
|
updated_by = $2,
|
||||||
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = 1
|
||||||
|
`, [dto.officeEmail ?? null, principal.userId]);
|
||||||
const after = await this.settings();
|
const after = await this.settings();
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService {
|
|||||||
async list() {
|
async list() {
|
||||||
const data = await this.dataSource.query(`
|
const data = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.id,
|
delivery.id,
|
||||||
d.act_id AS "actId",
|
delivery.act_id AS "actId",
|
||||||
d.report_id AS "reportId",
|
delivery.report_id AS "reportId",
|
||||||
d.document_kind AS "documentKind",
|
delivery.document_kind AS "documentKind",
|
||||||
d.recipient_kind AS "recipientKind",
|
delivery.recipient_kind AS "recipientKind",
|
||||||
d.recipient_asset_id AS "recipientAssetId",
|
delivery.recipient_asset_id AS "recipientAssetId",
|
||||||
d.recipient_user_id AS "recipientUserId",
|
delivery.recipient_user_id AS "recipientUserId",
|
||||||
d.recipient_email AS "recipientEmail",
|
delivery.recipient_email AS "recipientEmail",
|
||||||
d.status,
|
delivery.status,
|
||||||
d.attempts,
|
delivery.attempts,
|
||||||
d.last_attempt_at AS "lastAttemptAt",
|
delivery.last_attempt_at AS "lastAttemptAt",
|
||||||
d.sent_at AS "sentAt",
|
delivery.sent_at AS "sentAt",
|
||||||
d.provider_message_id AS "providerMessageId",
|
delivery.provider_message_id AS "providerMessageId",
|
||||||
d.last_error AS "lastError",
|
delivery.last_error AS "lastError",
|
||||||
d.created_at AS "createdAt",
|
delivery.created_at AS "createdAt",
|
||||||
a.code AS "actCode",
|
act.code AS "actCode",
|
||||||
r.code AS "reportCode",
|
report.code AS "reportCode",
|
||||||
recipient.name AS "recipientAssetName",
|
recipient.name AS "recipientAssetName",
|
||||||
CASE
|
CASE
|
||||||
WHEN recipient_user.id IS NULL THEN NULL
|
WHEN recipient_user.id IS NULL THEN NULL
|
||||||
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
||||||
END AS "recipientUserName"
|
END AS "recipientUserName"
|
||||||
FROM inspection_document_deliveries d
|
FROM inspection_document_deliveries delivery
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
JOIN inspection_acts act ON act.id = delivery.act_id
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
|
||||||
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
|
LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id
|
||||||
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id
|
||||||
ORDER BY d.created_at DESC
|
ORDER BY delivery.created_at DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`);
|
`);
|
||||||
return { data };
|
return { data };
|
||||||
@@ -120,13 +113,14 @@ export class InspectionDocumentDeliveryService {
|
|||||||
async dispatchForAct(actId: string): Promise<void> {
|
async dispatchForAct(actId: string): Promise<void> {
|
||||||
await this.pdf.ensure(actId).catch(() => undefined);
|
await this.pdf.ensure(actId).catch(() => undefined);
|
||||||
const [report] = await this.dataSource.query(
|
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],
|
[actId],
|
||||||
) as Array<{ id: string }>;
|
) as Array<{ id: string }>;
|
||||||
if (report) await this.word.ensure(report.id);
|
if (report) await this.word.ensure(report.id);
|
||||||
await this.ensureRows(actId, report?.id ?? null);
|
await this.ensureRows(actId, report?.id ?? null);
|
||||||
const rows = await this.rowsForAct(actId);
|
for (const row of await this.rowsForAct(actId)) {
|
||||||
for (const row of rows) await this.attempt(row).catch(() => undefined);
|
await this.attempt(row).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async retry(
|
async retry(
|
||||||
@@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService {
|
|||||||
const rows = await this.dataSource.query(`
|
const rows = await this.dataSource.query(`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM inspection_document_deliveries
|
FROM inspection_document_deliveries
|
||||||
WHERE status<>'SENT'
|
WHERE status <> 'SENT'
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`) as Array<{ id: string }>;
|
`) 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 };
|
return { processed: rows.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureRows(actId: string, reportId: string | null) {
|
private async ensureRows(actId: string, reportId: string | null) {
|
||||||
const [settings] = await this.dataSource.query(`
|
const [settings] = await this.dataSource.query(`
|
||||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
SELECT office_email AS "officeEmail"
|
||||||
FROM institutional_delivery_settings
|
FROM institutional_delivery_settings
|
||||||
WHERE id=1
|
WHERE id = 1
|
||||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
`) as Array<{ officeEmail: string | null }>;
|
||||||
|
|
||||||
const companies = await this.dataSource.query(`
|
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
|
FROM inspection_act_assets link
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
JOIN assets asset ON asset.id = link.asset_id
|
||||||
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
JOIN assets company ON company.id=COALESCE(
|
JOIN assets company ON company.id = COALESCE(
|
||||||
asset.operator_company_id,
|
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
|
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
WHERE link.act_id = $1 AND link.included = true
|
||||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||||
|
|
||||||
const [inspector] = await this.dataSource.query(`
|
const [inspector] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT inspector.id, inspector.email
|
||||||
inspector.id,
|
|
||||||
inspector.email
|
|
||||||
FROM inspection_acts act
|
FROM inspection_acts act
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
|
||||||
WHERE act.id=$1
|
WHERE act.id = $1
|
||||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||||
|
|
||||||
for (const company of companies) {
|
for (const company of companies) {
|
||||||
@@ -229,27 +223,26 @@ export class InspectionDocumentDeliveryService {
|
|||||||
recipientKey: inspector.id,
|
recipientKey: inspector.id,
|
||||||
recipientEmail: inspector.email,
|
recipientEmail: inspector.email,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (reportId) {
|
if (reportId) {
|
||||||
await this.upsertRow({
|
await this.upsertRow({
|
||||||
actId,
|
actId,
|
||||||
reportId,
|
reportId,
|
||||||
documentKind: 'REPORT_WORD',
|
documentKind: 'REPORT_WORD',
|
||||||
recipientKind: 'DIRECTOR',
|
recipientKind: 'INSPECTOR',
|
||||||
recipientAssetId: null,
|
recipientAssetId: null,
|
||||||
recipientUserId: null,
|
recipientUserId: inspector.id,
|
||||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
recipientKey: inspector.id,
|
||||||
recipientEmail: settings?.directorEmail ?? null,
|
recipientEmail: inspector.email,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async upsertRow(input: {
|
private async upsertRow(input: {
|
||||||
actId: string;
|
actId: string;
|
||||||
reportId: string | null;
|
reportId: string | null;
|
||||||
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||||
recipientKind: DeliveryRecipientKind;
|
recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
|
||||||
recipientAssetId: string | null;
|
recipientAssetId: string | null;
|
||||||
recipientUserId: string | null;
|
recipientUserId: string | null;
|
||||||
recipientKey: string;
|
recipientKey: string;
|
||||||
@@ -258,23 +251,23 @@ export class InspectionDocumentDeliveryService {
|
|||||||
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
|
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
INSERT INTO inspection_document_deliveries (
|
INSERT INTO inspection_document_deliveries (
|
||||||
act_id,report_id,document_kind,recipient_kind,
|
act_id, report_id, document_kind, recipient_kind,
|
||||||
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
|
recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
|
||||||
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
|
ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET
|
||||||
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),
|
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_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_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id),
|
||||||
recipient_email=CASE
|
recipient_email = CASE
|
||||||
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email
|
||||||
ELSE EXCLUDED.recipient_email
|
ELSE EXCLUDED.recipient_email
|
||||||
END,
|
END,
|
||||||
status=CASE
|
status = CASE
|
||||||
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT'
|
||||||
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||||
ELSE inspection_document_deliveries.status
|
ELSE inspection_document_deliveries.status
|
||||||
END,
|
END,
|
||||||
updated_at=CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
`, [
|
`, [
|
||||||
input.actId,
|
input.actId,
|
||||||
input.reportId,
|
input.reportId,
|
||||||
@@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService {
|
|||||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||||
return this.dataSource.query(`
|
return this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
delivery.id,
|
||||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
delivery.act_id AS "actId",
|
||||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
delivery.report_id AS "reportId",
|
||||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
delivery.document_kind AS "documentKind",
|
||||||
a.code AS "actCode",r.code AS "reportCode"
|
delivery.recipient_kind AS "recipientKind",
|
||||||
FROM inspection_document_deliveries d
|
delivery.recipient_asset_id AS "recipientAssetId",
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
delivery.recipient_user_id AS "recipientUserId",
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
delivery.recipient_email AS "recipientEmail",
|
||||||
WHERE d.act_id=$1
|
delivery.status,
|
||||||
ORDER BY d.created_at
|
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<DeliveryRow[]>;
|
`, [actId]) as Promise<DeliveryRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(id: string): Promise<DeliveryRow> {
|
private async load(id: string): Promise<DeliveryRow> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
delivery.id,
|
||||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
delivery.act_id AS "actId",
|
||||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
delivery.report_id AS "reportId",
|
||||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
delivery.document_kind AS "documentKind",
|
||||||
a.code AS "actCode",r.code AS "reportCode"
|
delivery.recipient_kind AS "recipientKind",
|
||||||
FROM inspection_document_deliveries d
|
delivery.recipient_asset_id AS "recipientAssetId",
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
delivery.recipient_user_id AS "recipientUserId",
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
delivery.recipient_email AS "recipientEmail",
|
||||||
WHERE d.id=$1
|
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[];
|
`, [id]) as DeliveryRow[];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
@@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService {
|
|||||||
const [company] = await this.dataSource.query(`
|
const [company] = await this.dataSource.query(`
|
||||||
SELECT notification_email AS email
|
SELECT notification_email AS email
|
||||||
FROM organization_profiles
|
FROM organization_profiles
|
||||||
WHERE asset_id=$1
|
WHERE asset_id = $1
|
||||||
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||||
email = company?.email ?? null;
|
email = company?.email ?? null;
|
||||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||||
const [inspector] = await this.dataSource.query(`
|
const [inspector] = await this.dataSource.query(`
|
||||||
SELECT email
|
SELECT email
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id=$1 AND is_active=true
|
WHERE id = $1 AND is_active = true
|
||||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||||
email = inspector?.email ?? null;
|
email = inspector?.email ?? null;
|
||||||
} else {
|
} else if (row.recipientKind === 'OFFICE') {
|
||||||
const [settings] = await this.dataSource.query(`
|
const [settings] = await this.dataSource.query(`
|
||||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
SELECT office_email AS "officeEmail"
|
||||||
FROM institutional_delivery_settings
|
FROM institutional_delivery_settings
|
||||||
WHERE id=1
|
WHERE id = 1
|
||||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
`) as Array<{ officeEmail: string | null }>;
|
||||||
email = row.recipientKind === 'OFFICE'
|
email = settings?.officeEmail ?? null;
|
||||||
? settings?.officeEmail ?? null
|
} else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') {
|
||||||
: settings?.directorEmail ?? null;
|
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(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET recipient_email=$2,
|
SET recipient_email = $2,
|
||||||
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||||
last_error=NULL,
|
last_error = NULL,
|
||||||
updated_at=CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id=$1 AND status<>'SENT'
|
WHERE id = $1 AND status <> 'SENT'
|
||||||
`, [row.id, email]);
|
`, [row.id, email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService {
|
|||||||
|
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
SET attempts = attempts + 1,
|
||||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
last_attempt_at = CURRENT_TIMESTAMP,
|
||||||
WHERE id=$1
|
status = 'PENDING',
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
`, [row.id]);
|
`, [row.id]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService {
|
|||||||
? `Acta ${row.actCode}`
|
? `Acta ${row.actCode}`
|
||||||
: `Informe ${row.reportCode ?? ''}`;
|
: `Informe ${row.reportCode ?? ''}`;
|
||||||
const text = row.documentKind === 'REPORT_WORD'
|
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'
|
: row.recipientKind === 'INSPECTOR'
|
||||||
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
|
||||||
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
: `Se adjunta el Acta ${row.actCode}.`;
|
||||||
const sent = await this.smtp.send({
|
const sent = await this.smtp.send({
|
||||||
to: row.recipientEmail,
|
to: row.recipientEmail,
|
||||||
subject: `DH Inspección · ${label}`,
|
subject: `DH Inspección · ${label}`,
|
||||||
@@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService {
|
|||||||
});
|
});
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
SET status = 'SENT',
|
||||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
sent_at = CURRENT_TIMESTAMP,
|
||||||
WHERE id=$1
|
provider_message_id = $2,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
`, [row.id, sent.messageId]);
|
`, [row.id, sent.messageId]);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||||
@@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService {
|
|||||||
private async setStatus(id: string, status: string, error: string) {
|
private async setStatus(id: string, status: string, error: string) {
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
|
SET status = $2,
|
||||||
WHERE id=$1
|
last_error = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
`, [id, status, error.slice(0, 500)]);
|
`, [id, status, error.slice(0, 500)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { InspectionReportWordService } from './inspection-report-word.service';
|
|||||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
import { SmtpSettingsController } from './smtp-settings.controller';
|
||||||
|
import { SmtpSettingsService } from './smtp-settings.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, InspectionDeadlinesModule],
|
imports: [AuditModule, InspectionDeadlinesModule],
|
||||||
@@ -22,6 +24,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
|||||||
InspectionReportDossierController,
|
InspectionReportDossierController,
|
||||||
InspectionReportFollowUpFileController,
|
InspectionReportFollowUpFileController,
|
||||||
DocumentDeliveryController,
|
DocumentDeliveryController,
|
||||||
|
SmtpSettingsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
InspectionReportsService,
|
InspectionReportsService,
|
||||||
@@ -30,6 +33,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
|||||||
InspectionActPdfService,
|
InspectionActPdfService,
|
||||||
InspectionDocumentDeliveryService,
|
InspectionDocumentDeliveryService,
|
||||||
SmtpDeliveryService,
|
SmtpDeliveryService,
|
||||||
|
SmtpSettingsService,
|
||||||
],
|
],
|
||||||
exports: [InspectionReportsService],
|
exports: [InspectionReportsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,37 +1,328 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { connect as connectNet, Socket } from 'node:net';
|
import { connect as connectNet, Socket } from 'node:net';
|
||||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
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 { 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 MailAttachment {
|
||||||
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
|
filename: string;
|
||||||
interface Reply { code:number; text: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 {
|
class SmtpSession {
|
||||||
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
|
private buffer = '';
|
||||||
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
|
private waiters: Array<(reply: Reply) => void> = [];
|
||||||
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
|
private replyLines: string[] = [];
|
||||||
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); }
|
private readonly dataHandler = (chunk: Buffer | string) =>
|
||||||
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; }
|
this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
||||||
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); } } }
|
constructor(private socket: Socket | TLSSocket) {
|
||||||
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(reply)}); });}
|
this.attach(socket);
|
||||||
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();}
|
private attach(socket: Socket | TLSSocket) {
|
||||||
current(){return this.socket;}
|
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<Reply> {
|
||||||
|
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 encodedSubject(value: string) {
|
||||||
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
|
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||||
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 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()
|
@Injectable()
|
||||||
export class SmtpDeliveryService {
|
export class SmtpDeliveryService implements OnModuleInit {
|
||||||
constructor(private readonly config:ConfigService){}
|
private runtime: RuntimeSmtpConfig | null;
|
||||||
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
|
private runtimeError: string | null = null;
|
||||||
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((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<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
|
|
||||||
if(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); } }
|
constructor(
|
||||||
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()}; }
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.runtime = this.environmentConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string>('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<Socket | TLSSocket>((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<TLSSocket>((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<string>('SMTP_HOST');
|
||||||
|
const from = this.config.get<string>('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<string>('SMTP_PORT') ?? 587),
|
||||||
|
securityMode: String(this.config.get<string>('SMTP_SECURE') ?? 'false').toLowerCase() === 'true'
|
||||||
|
? 'TLS'
|
||||||
|
: 'STARTTLS',
|
||||||
|
user: this.config.get<string>('SMTP_USER') ?? '',
|
||||||
|
pass: this.config.get<string>('SMTP_PASS') ?? '',
|
||||||
|
fromHeader: from,
|
||||||
|
fromEmail,
|
||||||
|
replyTo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>('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<SmtpSettingsRow> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user