F4.5 · SMTP Superadmin y Report Word al Inspector

This commit is contained in:
2026-09-07 20:00:53 -03:00
parent 3634768f9a
commit 890b54f7c8
11 changed files with 958 additions and 158 deletions
@@ -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,
};
}
}