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';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 { 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<string>('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<void> {
|
||||
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<DeliveryRecipientKind, 'DIRECTOR'>;
|
||||
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<DeliveryRow[]> {
|
||||
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<DeliveryRow[]>;
|
||||
}
|
||||
|
||||
private async load(id: string): Promise<DeliveryRow> {
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<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)}); });}
|
||||
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<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 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<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
|
||||
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); } }
|
||||
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<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user