panels-origin/api/smtp.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

161 lines
4.6 KiB
TypeScript

import type { PlatformDb } from "./platform_db.ts";
import { config } from "./config.ts";
export type SmtpSettings = {
host: string;
port: number;
username: string;
password: string;
from_address: string;
enabled: boolean;
updated_at?: string;
};
export type SmtpPublic = {
host: string;
port: number;
username: string;
from_address: string;
enabled: boolean;
configured: boolean;
has_password: boolean;
updated_at: string | null;
source: "db" | "env" | "none";
};
type SmtpRow = {
host: string;
port: number;
username: string;
password: string;
from_address: string;
enabled: boolean;
updated_at: string;
};
function trim(v: unknown): string {
return (v ?? "").toString().trim();
}
function fromEnv(): SmtpSettings {
return {
host: config.smtpHost,
port: config.smtpPort || 587,
username: config.smtpUser,
password: config.smtpPass,
from_address: config.smtpFrom,
enabled: Boolean(config.smtpHost && config.smtpFrom),
};
}
async function readRow(platformDb: PlatformDb): Promise<SmtpRow | undefined> {
try {
return await platformDb.prepare(
`SELECT host, port, username, password, from_address, enabled, updated_at
FROM smtp_settings WHERE id = 1`,
).get() as SmtpRow | undefined;
} catch {
return undefined;
}
}
/** Config efectiva: fila en panels_platform si hay host; si no, variables de entorno. */
export async function resolveSmtp(
platformDb: PlatformDb,
): Promise<SmtpSettings & { source: "db" | "env" | "none" }> {
const row = await readRow(platformDb);
if (row && trim(row.host)) {
return {
host: trim(row.host),
port: Number(row.port) || 587,
username: trim(row.username),
password: row.password ?? "",
from_address: trim(row.from_address) || config.smtpFrom,
enabled: Boolean(row.enabled),
updated_at: row.updated_at,
source: "db",
};
}
const env = fromEnv();
if (env.host) return { ...env, source: "env" };
return { ...env, enabled: false, source: "none" };
}
export async function smtpConfigured(platformDb: PlatformDb): Promise<boolean> {
const s = await resolveSmtp(platformDb);
return s.enabled && Boolean(s.host && s.from_address);
}
export async function smtpPublicView(platformDb: PlatformDb): Promise<SmtpPublic> {
const s = await resolveSmtp(platformDb);
return {
host: s.host,
port: s.port,
username: s.username,
from_address: s.from_address,
enabled: s.enabled,
configured: await smtpConfigured(platformDb),
has_password: Boolean(s.password),
updated_at: s.updated_at ?? null,
source: s.source,
};
}
export type SaveSmtpInput = {
host?: string;
port?: number | string;
username?: string;
password?: string;
from_address?: string;
enabled?: boolean;
/** Si true y password vacío, conserva la contraseña actual. */
keep_password?: boolean;
};
function looksLikeEmailFrom(value: string): boolean {
// Acepta "user@dom.com" o "Nombre <user@dom.com>"
return /(?:^|<)[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+(?:>|$)/.test(value);
}
export async function saveSmtpSettings(
platformDb: PlatformDb,
input: SaveSmtpInput,
): Promise<{ error?: string; settings?: SmtpPublic }> {
const host = trim(input.host);
const fromAddress = trim(input.from_address);
const username = trim(input.username);
const port = Number(input.port ?? 587);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
return { error: "Puerto SMTP inválido" };
}
const enabled = input.enabled !== false;
if (enabled && !host) return { error: "Host SMTP obligatorio si está habilitado" };
if (enabled && !fromAddress) return { error: "Remitente (From) obligatorio si está habilitado" };
if (enabled && fromAddress && !looksLikeEmailFrom(fromAddress)) {
return {
error: 'Remitente inválido. Use un correo, ej. PANELS <noreply@tudominio.com>',
};
}
const current = await readRow(platformDb);
let password = input.password ?? "";
if ((input.keep_password || password === "") && current?.password) {
password = current.password;
}
await platformDb.prepare(
`INSERT INTO smtp_settings (id, host, port, username, password, from_address, enabled, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?, now())
ON CONFLICT(id) DO UPDATE SET
host = excluded.host,
port = excluded.port,
username = excluded.username,
password = excluded.password,
from_address = excluded.from_address,
enabled = excluded.enabled,
updated_at = now()`,
).run(host, port, username, password, fromAddress, enabled);
return { settings: await smtpPublicView(platformDb) };
}