From e1d5975e579f7cf7eec93d49790965297a4100fd Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 15 Apr 2026 09:33:50 -0400 Subject: [PATCH] feat: add email settings management - Introduced EmailSettings component for managing SMTP services. - Added API endpoints for organization email services including CRUD operations. - Created database migrations for organization_email_settings and organization_email_services tables. - Updated the settings page to include EmailSettings component. - Implemented validation and error handling for email service operations. --- Cargo.lock | 69 ++ docker-compose.yml | 22 + ...0414000002_organization_email_settings.sql | 14 + ...0414000003_organization_email_services.sql | 58 ++ .../src/handlers_email_settings.rs | 875 ++++++++++++++++++ services/cms-service/src/main.rs | 34 +- services/lms-service/Cargo.toml | 1 + .../lms-service/src/handlers_discussions.rs | 390 +++++++- services/lms-service/src/main.rs | 14 +- web/studio/src/app/settings/page.tsx | 2 + web/studio/src/components/EmailSettings.tsx | 374 ++++++++ web/studio/src/lib/api.ts | 42 + 12 files changed, 1877 insertions(+), 18 deletions(-) create mode 100644 services/cms-service/migrations/20260414000002_organization_email_settings.sql create mode 100644 services/cms-service/migrations/20260414000003_organization_email_services.sql create mode 100644 services/cms-service/src/handlers_email_settings.rs create mode 100644 web/studio/src/components/EmailSettings.tsx diff --git a/Cargo.lock b/Cargo.lock index c990db5..334589b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,17 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -1244,6 +1255,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2111,6 +2138,32 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2 0.6.1", + "tokio", + "tokio-native-tls", + "url", +] + [[package]] name = "libc" version = "0.2.178" @@ -2170,6 +2223,7 @@ dependencies = [ "dotenvy", "http 1.4.0", "jsonwebtoken", + "lettre", "mime_guess", "reqwest 0.12.26", "serde", @@ -2319,6 +2373,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonempty" version = "0.7.0" @@ -2778,6 +2841,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" diff --git a/docker-compose.yml b/docker-compose.yml index d507059..6c1e943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,22 @@ services: timeout: 5s retries: 5 + # ======================================== + # SMTP Relay Interno (Mailpit) + # ======================================== + mailpit: + image: axllent/mailpit:latest + container_name: openccb-mailpit + # SMTP para servicios internos y UI solo accesible por SSH túnel/localhost + ports: + - "127.0.0.1:8025:8025" + environment: + - MP_SMTP_BIND_ADDR=0.0.0.0:1025 + - MP_UI_BIND_ADDR=0.0.0.0:8025 + networks: + - openccb-network + restart: always + # ======================================== # Studio + CMS (HTTPS) # ======================================== @@ -126,6 +142,12 @@ services: - HOSTNAME=0.0.0.0 - DATABASE_URL=${LMS_DATABASE_URL} - NEXT_PUBLIC_CMS_API_URL=${NEXT_PUBLIC_CMS_API_URL} + - SMTP_ENABLED=${SMTP_ENABLED:-false} + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_FROM=${SMTP_FROM:-OpenCCB } + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} env_file: .env extra_hosts: - "host.docker.internal:host-gateway" diff --git a/services/cms-service/migrations/20260414000002_organization_email_settings.sql b/services/cms-service/migrations/20260414000002_organization_email_settings.sql new file mode 100644 index 0000000..9709b74 --- /dev/null +++ b/services/cms-service/migrations/20260414000002_organization_email_settings.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS organization_email_settings ( + organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + smtp_enabled BOOLEAN NOT NULL DEFAULT FALSE, + smtp_host TEXT, + smtp_port INTEGER NOT NULL DEFAULT 587, + smtp_from TEXT, + smtp_username TEXT, + smtp_password TEXT, + smtp_starttls BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_email_settings_org_id ON organization_email_settings(organization_id); diff --git a/services/cms-service/migrations/20260414000003_organization_email_services.sql b/services/cms-service/migrations/20260414000003_organization_email_services.sql new file mode 100644 index 0000000..2dc8ae7 --- /dev/null +++ b/services/cms-service/migrations/20260414000003_organization_email_services.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS organization_email_services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + service_type TEXT NOT NULL DEFAULT 'smtp', + provider_key TEXT NOT NULL DEFAULT 'custom', + display_name TEXT NOT NULL DEFAULT 'SMTP principal', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + smtp_host TEXT, + smtp_port INTEGER NOT NULL DEFAULT 587, + smtp_from TEXT, + smtp_username TEXT, + smtp_password TEXT, + smtp_starttls BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_email_services_org_id ON organization_email_services(organization_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_org_email_services_default_per_org + ON organization_email_services(organization_id) + WHERE is_default = TRUE; + +INSERT INTO organization_email_services ( + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls, + created_at, + updated_at +) +SELECT + s.organization_id, + 'smtp', + 'custom', + 'SMTP principal', + s.smtp_enabled, + TRUE, + s.smtp_host, + s.smtp_port, + s.smtp_from, + s.smtp_username, + s.smtp_password, + s.smtp_starttls, + NOW(), + NOW() +FROM organization_email_settings s +LEFT JOIN organization_email_services es + ON es.organization_id = s.organization_id +WHERE es.id IS NULL; diff --git a/services/cms-service/src/handlers_email_settings.rs b/services/cms-service/src/handlers_email_settings.rs new file mode 100644 index 0000000..bc6d380 --- /dev/null +++ b/services/cms-service/src/handlers_email_settings.rs @@ -0,0 +1,875 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::auth::Claims; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use std::env; +use uuid::Uuid; + +use super::handlers::{Org, log_action}; + +#[derive(Debug, Clone, sqlx::FromRow)] +struct OrganizationEmailServiceRow { + id: Uuid, + organization_id: Uuid, + service_type: String, + provider_key: String, + display_name: String, + is_enabled: bool, + is_default: bool, + smtp_host: Option, + smtp_port: i32, + smtp_from: Option, + smtp_username: Option, + smtp_password: Option, + smtp_starttls: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OrganizationEmailServiceResponse { + pub id: Uuid, + pub organization_id: Uuid, + pub service_type: String, + pub provider_key: String, + pub display_name: String, + pub is_enabled: bool, + pub is_default: bool, + pub smtp_host: Option, + pub smtp_port: i32, + pub smtp_from: Option, + pub smtp_username: Option, + pub smtp_starttls: bool, + pub has_password: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UpsertOrganizationEmailServicePayload { + pub service_type: String, + pub provider_key: String, + pub display_name: String, + pub is_enabled: bool, + pub is_default: bool, + pub smtp_host: Option, + pub smtp_port: i32, + pub smtp_from: Option, + pub smtp_username: Option, + pub smtp_password: Option, + pub smtp_starttls: bool, +} + +fn parse_bool(value: &str) -> bool { + let normalized = value.trim().to_lowercase(); + normalized == "1" || normalized == "true" || normalized == "yes" +} + +fn normalize_optional(value: Option) -> Option { + value.and_then(|v| { + let trimmed = v.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn validate_payload(payload: &UpsertOrganizationEmailServicePayload) -> Result<(), (StatusCode, String)> { + if payload.smtp_port <= 0 || payload.smtp_port > 65535 { + return Err(( + StatusCode::BAD_REQUEST, + "smtp_port debe estar entre 1 y 65535".to_string(), + )); + } + + if payload.service_type.trim().to_lowercase() != "smtp" { + return Err(( + StatusCode::BAD_REQUEST, + "Por ahora solo se soporta service_type='smtp'".to_string(), + )); + } + + if payload.is_enabled { + let host_ok = payload + .smtp_host + .as_ref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + let from_ok = payload + .smtp_from + .as_ref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false); + + if !host_ok { + return Err(( + StatusCode::BAD_REQUEST, + "smtp_host es requerido cuando el servicio está habilitado".to_string(), + )); + } + + if !from_ok { + return Err(( + StatusCode::BAD_REQUEST, + "smtp_from es requerido cuando el servicio está habilitado".to_string(), + )); + } + } + + Ok(()) +} + +fn to_response(row: OrganizationEmailServiceRow) -> OrganizationEmailServiceResponse { + OrganizationEmailServiceResponse { + id: row.id, + organization_id: row.organization_id, + service_type: row.service_type, + provider_key: row.provider_key, + display_name: row.display_name, + is_enabled: row.is_enabled, + is_default: row.is_default, + smtp_host: row.smtp_host, + smtp_port: row.smtp_port, + smtp_from: row.smtp_from, + smtp_username: row.smtp_username, + smtp_starttls: row.smtp_starttls, + has_password: row + .smtp_password + .as_ref() + .map(|p| !p.trim().is_empty()) + .unwrap_or(false), + } +} + +async fn ensure_bootstrap_service(pool: &PgPool, organization_id: Uuid) -> Result<(), (StatusCode, String)> { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1", + ) + .bind(organization_id) + .fetch_one(pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al verificar servicios de email: {}", e), + ) + })?; + + if count > 0 { + return Ok(()); + } + + let enabled = env::var("SMTP_ENABLED") + .map(|v| parse_bool(&v)) + .unwrap_or(false); + let host = normalize_optional(env::var("SMTP_HOST").ok()); + let port = env::var("SMTP_PORT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0 && *v <= 65535) + .unwrap_or(587); + let from = normalize_optional(env::var("SMTP_FROM").ok()); + let username = normalize_optional(env::var("SMTP_USERNAME").ok()); + let password = normalize_optional(env::var("SMTP_PASSWORD").ok()); + let starttls = env::var("SMTP_STARTTLS") + .map(|v| parse_bool(&v)) + .unwrap_or(true); + let provider_key = env::var("EMAIL_PROVIDER") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "custom".to_string()); + let display_name = env::var("EMAIL_SERVICE_NAME") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| format!("SMTP {}", provider_key.to_uppercase())); + + sqlx::query( + r#" + INSERT INTO organization_email_services ( + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls, + updated_at + ) + VALUES ($1, 'smtp', $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, NOW()) + "#, + ) + .bind(organization_id) + .bind(provider_key) + .bind(display_name) + .bind(enabled) + .bind(host) + .bind(port) + .bind(from) + .bind(username) + .bind(password) + .bind(starttls) + .execute(pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al bootstrapear servicio SMTP desde entorno: {}", e), + ) + })?; + + Ok(()) +} + +async fn select_default_service( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + organization_id: Uuid, + service_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE organization_email_services SET is_default = FALSE WHERE organization_id = $1", + ) + .bind(organization_id) + .execute(&mut **tx) + .await?; + + sqlx::query( + "UPDATE organization_email_services SET is_default = TRUE, updated_at = NOW() WHERE id = $1 AND organization_id = $2", + ) + .bind(service_id) + .bind(organization_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub async fn list_organization_email_services( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, +) -> Result>, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + ensure_bootstrap_service(&pool, org_ctx.id).await?; + + let rows = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + SELECT + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + FROM organization_email_services + WHERE organization_id = $1 + ORDER BY is_default DESC, created_at ASC + "#, + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al cargar servicios de email: {}", e), + ) + })?; + + Ok(Json(rows.into_iter().map(to_response).collect())) +} + +pub async fn create_organization_email_service( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + validate_payload(&payload)?; + + let service_type = payload.service_type.trim().to_lowercase(); + let provider_key = payload.provider_key.trim().to_lowercase(); + let display_name = payload.display_name.trim().to_string(); + let smtp_host = normalize_optional(payload.smtp_host); + let smtp_from = normalize_optional(payload.smtp_from); + let smtp_username = normalize_optional(payload.smtp_username); + let smtp_password = normalize_optional(payload.smtp_password); + + let mut tx = pool.begin().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al abrir transacción: {}", e), + ) + })?; + + let existing_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1", + ) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al contar servicios de email: {}", e), + ) + })?; + + let make_default = payload.is_default || existing_count == 0; + + let row = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + INSERT INTO organization_email_services ( + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, FALSE, $6, $7, $8, $9, $10, $11, NOW()) + RETURNING + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + "#, + ) + .bind(org_ctx.id) + .bind(service_type) + .bind(provider_key) + .bind(display_name) + .bind(payload.is_enabled) + .bind(smtp_host) + .bind(payload.smtp_port) + .bind(smtp_from) + .bind(smtp_username) + .bind(smtp_password) + .bind(payload.smtp_starttls) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al crear servicio de email: {}", e), + ) + })?; + + if make_default { + select_default_service(&mut tx, org_ctx.id, row.id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al establecer servicio por defecto: {}", e), + ) + })?; + } + + let row = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + SELECT + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + FROM organization_email_services + WHERE id = $1 AND organization_id = $2 + "#, + ) + .bind(row.id) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al recargar servicio de email: {}", e), + ) + })?; + + tx.commit().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al confirmar transacción: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "CREATE_EMAIL_SERVICE", + "Organization", + org_ctx.id, + json!({ + "service_id": row.id, + "service_type": row.service_type, + "provider_key": row.provider_key, + "display_name": row.display_name, + "is_enabled": row.is_enabled, + "is_default": row.is_default, + }), + ) + .await; + + Ok(Json(to_response(row))) +} + +pub async fn update_organization_email_service( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + validate_payload(&payload)?; + + let service_type = payload.service_type.trim().to_lowercase(); + let provider_key = payload.provider_key.trim().to_lowercase(); + let display_name = payload.display_name.trim().to_string(); + let smtp_host = normalize_optional(payload.smtp_host); + let smtp_from = normalize_optional(payload.smtp_from); + let smtp_username = normalize_optional(payload.smtp_username); + let smtp_password = normalize_optional(payload.smtp_password); + + let mut tx = pool.begin().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al abrir transacción: {}", e), + ) + })?; + + let row = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + UPDATE organization_email_services + SET + service_type = $3, + provider_key = $4, + display_name = $5, + is_enabled = $6, + smtp_host = $7, + smtp_port = $8, + smtp_from = $9, + smtp_username = $10, + smtp_password = CASE + WHEN $11 IS NULL OR btrim($11) = '' THEN smtp_password + ELSE $11 + END, + smtp_starttls = $12, + updated_at = NOW() + WHERE id = $1 AND organization_id = $2 + RETURNING + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + "#, + ) + .bind(id) + .bind(org_ctx.id) + .bind(service_type) + .bind(provider_key) + .bind(display_name) + .bind(payload.is_enabled) + .bind(smtp_host) + .bind(payload.smtp_port) + .bind(smtp_from) + .bind(smtp_username) + .bind(smtp_password) + .bind(payload.smtp_starttls) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al actualizar servicio de email: {}", e), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string()))?; + + if payload.is_default { + select_default_service(&mut tx, org_ctx.id, row.id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al establecer servicio por defecto: {}", e), + ) + })?; + } + + let row = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + SELECT + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + FROM organization_email_services + WHERE id = $1 AND organization_id = $2 + "#, + ) + .bind(row.id) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al recargar servicio de email: {}", e), + ) + })?; + + tx.commit().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al confirmar transacción: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "UPDATE_EMAIL_SERVICE", + "Organization", + org_ctx.id, + json!({ + "service_id": row.id, + "service_type": row.service_type, + "provider_key": row.provider_key, + "display_name": row.display_name, + "is_enabled": row.is_enabled, + "is_default": row.is_default, + }), + ) + .await; + + Ok(Json(to_response(row))) +} + +pub async fn delete_organization_email_service( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + let mut tx = pool.begin().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al abrir transacción: {}", e), + ) + })?; + + let existing = sqlx::query_as::<_, (Uuid, bool)>( + "SELECT id, is_default FROM organization_email_services WHERE id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al verificar servicio: {}", e), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string()))?; + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM organization_email_services WHERE organization_id = $1", + ) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al contar servicios: {}", e), + ) + })?; + + if total <= 1 { + return Err(( + StatusCode::BAD_REQUEST, + "Debe existir al menos un servicio de email por organización".to_string(), + )); + } + + sqlx::query("DELETE FROM organization_email_services WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al eliminar servicio de email: {}", e), + ) + })?; + + if existing.1 { + let replacement_id: Uuid = sqlx::query_scalar( + "SELECT id FROM organization_email_services WHERE organization_id = $1 ORDER BY created_at ASC LIMIT 1", + ) + .bind(org_ctx.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al seleccionar reemplazo por defecto: {}", e), + ) + })?; + + select_default_service(&mut tx, org_ctx.id, replacement_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al actualizar servicio por defecto: {}", e), + ) + })?; + } + + tx.commit().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al confirmar transacción: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "DELETE_EMAIL_SERVICE", + "Organization", + org_ctx.id, + json!({ "service_id": id }), + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn select_organization_email_service( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM organization_email_services WHERE id = $1 AND organization_id = $2)", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al verificar servicio de email: {}", e), + ) + })?; + + if !exists { + return Err((StatusCode::NOT_FOUND, "Servicio no encontrado".to_string())); + } + + let mut tx = pool.begin().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al abrir transacción: {}", e), + ) + })?; + + select_default_service(&mut tx, org_ctx.id, id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al seleccionar servicio por defecto: {}", e), + ) + })?; + + tx.commit().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al confirmar transacción: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "SELECT_EMAIL_SERVICE", + "Organization", + org_ctx.id, + json!({ "service_id": id }), + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +// Compatibilidad temporal: mantiene el endpoint anterior pero devuelve el servicio por defecto. +pub async fn get_organization_email_settings( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + ensure_bootstrap_service(&pool, org_ctx.id).await?; + + let row = sqlx::query_as::<_, OrganizationEmailServiceRow>( + r#" + SELECT + id, + organization_id, + service_type, + provider_key, + display_name, + is_enabled, + is_default, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + FROM organization_email_services + WHERE organization_id = $1 + ORDER BY is_default DESC, created_at ASC + LIMIT 1 + "#, + ) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al obtener servicio SMTP por defecto: {}", e), + ) + })?; + + Ok(Json(to_response(row))) +} + +// Compatibilidad temporal: actualiza el servicio por defecto actual. +pub async fn update_organization_email_settings( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + ensure_bootstrap_service(&pool, org_ctx.id).await?; + + let current_id: Uuid = sqlx::query_scalar( + "SELECT id FROM organization_email_services WHERE organization_id = $1 ORDER BY is_default DESC, created_at ASC LIMIT 1", + ) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al resolver servicio SMTP actual: {}", e), + ) + })?; + + update_organization_email_service( + claims, + Org(org_ctx), + State(pool), + Path(current_id), + Json(payload), + ) + .await +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 880a072..3d3cfe2 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -3,6 +3,7 @@ pub mod exporter; mod external_handlers; mod handlers; mod handlers_branding; +mod handlers_email_settings; mod handlers_exercise_settings; mod handlers_assets; mod handlers_dependencies; @@ -162,16 +163,16 @@ async fn main() { .expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE, header::CONTENT_RANGE, header::ACCEPT_RANGES]); use tower_governor::governor::GovernorConfigBuilder; + use tower_governor::key_extractor::SmartIpKeyExtractor; use tower_governor::GovernorLayer; use std::sync::Arc; - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .per_second(5) // CMS usually has more complex operations, slightly lower limit - .burst_size(20) - .finish() - .unwrap(), - ); + let mut governor_conf = GovernorConfigBuilder::default() + .const_per_second(5) // CMS usually has more complex operations, slightly lower limit + .const_burst_size(20) + .key_extractor(SmartIpKeyExtractor); + + let governor_conf = Arc::new(governor_conf.finish().unwrap()); // Rutas protegidas que requieren autenticación y contexto de organización let protected_routes = Router::new() @@ -311,6 +312,25 @@ async fn main() { get(handlers_exercise_settings::get_organization_exercise_settings) .put(handlers_exercise_settings::update_organization_exercise_settings), ) + .route( + "/organization/email-settings", + get(handlers_email_settings::get_organization_email_settings) + .put(handlers_email_settings::update_organization_email_settings), + ) + .route( + "/organization/email-services", + get(handlers_email_settings::list_organization_email_services) + .post(handlers_email_settings::create_organization_email_service), + ) + .route( + "/organization/email-services/{id}", + put(handlers_email_settings::update_organization_email_service) + .delete(handlers_email_settings::delete_organization_email_service), + ) + .route( + "/organization/email-services/{id}/select", + post(handlers_email_settings::select_organization_email_service), + ) // Rutas de librerías de contenido .route( "/library/blocks", diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index d805223..0f72b93 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -30,3 +30,4 @@ mime_guess = "2.0" aws-config = "1" aws-sdk-s3 = "1" sha2.workspace = true +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index ba26368..b16f173 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -3,13 +3,250 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, }; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use common::auth::Claims; use common::middleware::Org; use common::models::{DiscussionPost, DiscussionThread, PostWithAuthor, ThreadWithAuthor}; use serde::Deserialize; use sqlx::PgPool; +use std::env; use uuid::Uuid; +#[derive(Debug, Clone)] +struct ForumEmailRecipient { + user_id: Uuid, + email: String, + full_name: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +struct OrganizationEmailSettingsRow { + service_type: String, + smtp_enabled: bool, + smtp_host: Option, + smtp_port: i32, + smtp_from: Option, + smtp_username: Option, + smtp_password: Option, + smtp_starttls: bool, +} + +#[derive(Debug, Clone)] +struct SmtpConfig { + enabled: bool, + host: String, + from: String, + port: u16, + starttls: bool, + username: Option, + password: Option, +} + +fn parse_bool(value: &str) -> bool { + let normalized = value.trim().to_lowercase(); + normalized == "1" || normalized == "true" || normalized == "yes" +} + +fn load_env_smtp_config() -> Result { + let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false); + let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?; + let from = env::var("SMTP_FROM") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "OpenCCB ".to_string()); + let port = env::var("SMTP_PORT") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(587); + let starttls = env::var("SMTP_STARTTLS") + .map(|v| { + let normalized = v.trim().to_lowercase(); + normalized == "1" || normalized == "true" || normalized == "yes" + }) + .unwrap_or(false); + let username = env::var("SMTP_USERNAME").ok().filter(|v| !v.trim().is_empty()); + let password = env::var("SMTP_PASSWORD").ok().filter(|v| !v.trim().is_empty()); + + Ok(SmtpConfig { + enabled, + host, + from, + port, + starttls, + username, + password, + }) +} + +async fn load_org_smtp_config(pool: &PgPool, organization_id: Uuid) -> Option { + let row = sqlx::query_as::<_, OrganizationEmailSettingsRow>( + r#" + SELECT + service_type, + smtp_enabled, + smtp_host, + smtp_port, + smtp_from, + smtp_username, + smtp_password, + smtp_starttls + FROM organization_email_services + WHERE organization_id = $1 + ORDER BY is_default DESC, created_at ASC + LIMIT 1 + "#, + ) + .bind(organization_id) + .fetch_optional(pool) + .await; + + let row = match row { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "No se pudo cargar configuración SMTP por organización (fallback a entorno): {}", + e + ); + return None; + } + }?; + + if row.service_type.trim().to_lowercase() != "smtp" { + tracing::warn!( + "El servicio de email por defecto no es SMTP ({}), se usa fallback de entorno", + row.service_type + ); + return None; + } + + let host = row.smtp_host.unwrap_or_default().trim().to_string(); + if host.is_empty() { + return None; + } + + let from = row + .smtp_from + .unwrap_or_else(|| "OpenCCB ".to_string()) + .trim() + .to_string(); + + let port = u16::try_from(row.smtp_port).ok().filter(|v| *v > 0).unwrap_or(587); + + Some(SmtpConfig { + enabled: row.smtp_enabled, + host, + from, + port, + starttls: row.smtp_starttls, + username: row.smtp_username.filter(|v| !v.trim().is_empty()), + password: row.smtp_password.filter(|v| !v.trim().is_empty()), + }) +} + +fn build_smtp_mailer(config: &SmtpConfig) -> Result, String> { + let mut builder = if config.starttls { + AsyncSmtpTransport::::relay(&config.host) + .map_err(|e| format!("Error al crear relay SMTP con STARTTLS: {}", e))? + .port(config.port) + } else { + AsyncSmtpTransport::::builder_dangerous(&config.host).port(config.port) + }; + + if let (Some(user), Some(pass)) = (&config.username, &config.password) { + let user = user.trim().to_string(); + let pass = pass.trim().to_string(); + builder = builder.credentials(Credentials::new(user, pass)); + } + + Ok(builder.build()) +} + +async fn send_forum_email_notifications( + pool: &PgPool, + organization_id: Uuid, + recipients: &[ForumEmailRecipient], + subject: &str, + body: &str, +) { + if recipients.is_empty() { + return; + } + + let smtp_config = match load_org_smtp_config(pool, organization_id).await { + Some(config) => config, + None => match load_env_smtp_config() { + Ok(config) => config, + Err(e) => { + tracing::warn!("SMTP deshabilitado para foros: {}", e); + return; + } + }, + }; + + if !smtp_config.enabled { + return; + } + + let mailer = match build_smtp_mailer(&smtp_config) { + Ok(v) => v, + Err(e) => { + tracing::warn!("SMTP deshabilitado para foros: {}", e); + return; + } + }; + + let from_mailbox: Mailbox = match smtp_config.from.parse() { + Ok(v) => v, + Err(e) => { + tracing::error!("SMTP_FROM inválido ({}): {}", smtp_config.from, e); + return; + } + }; + + for recipient in recipients { + let to_mailbox: Mailbox = match recipient.email.parse() { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "Email inválido para notificación de foro ({}): {}", + recipient.email, + e + ); + continue; + } + }; + + let personalized_body = if let Some(name) = &recipient.full_name { + format!("Hola {},\n\n{}\n\nOpenCCB", name, body) + } else { + format!("Hola,\n\n{}\n\nOpenCCB", body) + }; + + let message = match Message::builder() + .from(from_mailbox.clone()) + .to(to_mailbox) + .subject(subject) + .body(personalized_body) + { + Ok(msg) => msg, + Err(e) => { + tracing::warn!("No se pudo construir correo de foro: {}", e); + continue; + } + }; + + if let Err(e) = mailer.send(message).await { + tracing::warn!( + "Falló envío SMTP de foro para {}: {}", + recipient.email, + e + ); + } + } +} + // ========== DTOs de Solicitud/Respuesta ========== #[derive(Deserialize)] @@ -126,6 +363,13 @@ pub async fn create_thread( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let author_name: Option = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_optional(&pool) + .await + .ok() + .flatten(); + let thread = sqlx::query_as::<_, DiscussionThread>( "INSERT INTO discussion_threads (organization_id, course_id, lesson_id, author_id, title, content) VALUES ($1, $2, $3, $4, $5, $6) @@ -135,8 +379,8 @@ pub async fn create_thread( .bind(course_id) .bind(payload.lesson_id) .bind(claims.sub) - .bind(payload.title) - .bind(payload.content) + .bind(&payload.title) + .bind(&payload.content) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -153,6 +397,61 @@ pub async fn create_thread( .execute(&pool) .await; + // Notificar a instructores/administradores del curso (excepto autor) + let instructor_recipients = sqlx::query_as::<_, (Uuid, String, Option)>( + "SELECT DISTINCT u.id, u.email, u.full_name + FROM course_instructors ci + JOIN users u ON u.id = ci.user_id + WHERE ci.course_id = $1 + AND ci.organization_id = $2 + AND u.id != $3 + AND u.email IS NOT NULL + AND trim(u.email) != ''", + ) + .bind(course_id) + .bind(org_ctx.id) + .bind(claims.sub) + .fetch_all(&pool) + .await + .unwrap_or_default() + .into_iter() + .map(|(user_id, email, full_name)| ForumEmailRecipient { + user_id, + email, + full_name, + }) + .collect::>(); + + for recipient in &instructor_recipients { + let _ = sqlx::query( + "INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(org_ctx.id) + .bind(recipient.user_id) + .bind(format!("Nuevo hilo en foro: {}", thread.title)) + .bind(if thread.content.len() > 140 { + format!("{}...", &thread.content[..140]) + } else { + thread.content.clone() + }) + .bind("forum_thread") + .bind(format!("/courses/{}#discussions", course_id)) + .execute(&pool) + .await; + } + + let author_display = author_name.unwrap_or_else(|| "Un estudiante".to_string()); + let subject = format!("[OpenCCB] Nuevo hilo: {}", thread.title); + let body = format!( + "{} creó un nuevo hilo en el curso.\n\nTítulo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions", + author_display, + thread.title, + thread.content, + course_id + ); + send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, &subject, &body).await; + Ok(Json(thread)) } @@ -325,8 +624,11 @@ pub async fn create_post( ) -> Result, (StatusCode, String)> { // Verificar si el hilo está bloqueado let thread = - sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1") + sqlx::query_as::<_, (bool, String, Uuid, Uuid)>( + "SELECT is_locked, title, course_id, author_id FROM discussion_threads WHERE id = $1 AND organization_id = $2", + ) .bind(thread_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?; @@ -335,6 +637,13 @@ pub async fn create_post( return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string())); } + let author_name: Option = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_optional(&pool) + .await + .ok() + .flatten(); + let post = sqlx::query_as::<_, DiscussionPost>( "INSERT INTO discussion_posts (organization_id, thread_id, parent_post_id, author_id, content) VALUES ($1, $2, $3, $4, $5) @@ -349,7 +658,80 @@ pub async fn create_post( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // TODO: Enviar notificaciones a los usuarios suscritos + // Notificar a los suscritos al hilo (excepto autor de la respuesta) + let mut recipients = sqlx::query_as::<_, (Uuid, String, Option)>( + "SELECT DISTINCT u.id, u.email, u.full_name + FROM discussion_subscriptions ds + JOIN users u ON u.id = ds.user_id + WHERE ds.thread_id = $1 + AND ds.organization_id = $2 + AND ds.user_id != $3 + AND u.email IS NOT NULL + AND trim(u.email) != ''", + ) + .bind(thread_id) + .bind(org_ctx.id) + .bind(claims.sub) + .fetch_all(&pool) + .await + .unwrap_or_default() + .into_iter() + .map(|(user_id, email, full_name)| ForumEmailRecipient { + user_id, + email, + full_name, + }) + .collect::>(); + + // Si el autor del hilo no está suscrito, añadirlo igualmente (si no es quien respondió) + if thread.3 != claims.sub && !recipients.iter().any(|r| r.user_id == thread.3) { + if let Ok(Some((email, full_name))) = sqlx::query_as::<_, (String, Option)>( + "SELECT email, full_name FROM users WHERE id = $1 AND organization_id = $2", + ) + .bind(thread.3) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + { + if !email.trim().is_empty() { + recipients.push(ForumEmailRecipient { + user_id: thread.3, + email, + full_name, + }); + } + } + } + + for recipient in &recipients { + let _ = sqlx::query( + "INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(org_ctx.id) + .bind(recipient.user_id) + .bind(format!("Nueva respuesta en: {}", thread.1)) + .bind(if post.content.len() > 140 { + format!("{}...", &post.content[..140]) + } else { + post.content.clone() + }) + .bind("forum_reply") + .bind(format!("/courses/{}#discussions", thread.2)) + .execute(&pool) + .await; + } + + let author_display = author_name.unwrap_or_else(|| "Un usuario".to_string()); + let subject = format!("[OpenCCB] Nueva respuesta en: {}", thread.1); + let body = format!( + "{} respondió en un hilo al que estás suscrito.\n\nHilo: {}\n\n{}\n\nIr al curso: /courses/{}#discussions", + author_display, + thread.1, + post.content, + thread.2 + ); + send_forum_email_notifications(&pool, org_ctx.id, &recipients, &subject, &body).await; Ok(Json(post)) } diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 0543086..4c88d55 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -120,16 +120,16 @@ async fn main() { .expose_headers([header::CONTENT_LENGTH, header::CONTENT_TYPE]); use tower_governor::governor::GovernorConfigBuilder; + use tower_governor::key_extractor::SmartIpKeyExtractor; use tower_governor::GovernorLayer; use std::sync::Arc; - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .per_second(10) - .burst_size(50) - .finish() - .unwrap(), - ); + let mut governor_conf = GovernorConfigBuilder::default() + .const_per_second(10) + .const_burst_size(50) + .key_extractor(SmartIpKeyExtractor); + + let governor_conf = Arc::new(governor_conf.finish().unwrap()); // Rate limiter solo para rutas protegidas (después del middleware de autenticación) let protected_routes = Router::new() diff --git a/web/studio/src/app/settings/page.tsx b/web/studio/src/app/settings/page.tsx index 5db9ba8..d258a7f 100644 --- a/web/studio/src/app/settings/page.tsx +++ b/web/studio/src/app/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; import BrandingSettings from "@/components/BrandingSettings"; +import EmailSettings from "@/components/EmailSettings"; import ExerciseFeatureSettings from "@/components/ExerciseFeatureSettings"; import PageLayout from "@/components/PageLayout"; import { useAuth } from "@/context/AuthContext"; @@ -28,6 +29,7 @@ export default function SettingsPage() { >
+
diff --git a/web/studio/src/components/EmailSettings.tsx b/web/studio/src/components/EmailSettings.tsx new file mode 100644 index 0000000..2f0947f --- /dev/null +++ b/web/studio/src/components/EmailSettings.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + cmsApi, + OrganizationEmailService, + UpsertOrganizationEmailServicePayload, +} from "@/lib/api"; + +type EditableService = { + id?: string; + display_name: string; + provider_key: string; + smtp_enabled: boolean; + is_default: boolean; + smtp_host: string; + smtp_port: number; + smtp_from: string; + smtp_username: string; + smtp_starttls: boolean; + has_password: boolean; +}; + +function toEditable(service: OrganizationEmailService): EditableService { + return { + id: service.id, + display_name: service.display_name, + provider_key: service.provider_key, + smtp_enabled: service.smtp_enabled, + is_default: service.is_default, + smtp_host: service.smtp_host || "", + smtp_port: service.smtp_port || 587, + smtp_from: service.smtp_from || "", + smtp_username: service.smtp_username || "", + smtp_starttls: service.smtp_starttls, + has_password: service.has_password, + }; +} + +function newServiceTemplate(): EditableService { + return { + display_name: "Nuevo servicio SMTP", + provider_key: "custom", + smtp_enabled: true, + is_default: false, + smtp_host: "", + smtp_port: 587, + smtp_from: "", + smtp_username: "", + smtp_starttls: true, + has_password: false, + }; +} + +export default function EmailSettings() { + const [services, setServices] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [form, setForm] = useState(newServiceTemplate()); + const [smtpPassword, setSmtpPassword] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const selectedService = useMemo( + () => services.find((svc) => svc.id === selectedId), + [services, selectedId], + ); + + const loadServices = async () => { + const data = await cmsApi.listOrganizationEmailServices(); + setServices(data); + const defaultService = data.find((svc) => svc.is_default) || data[0]; + if (defaultService) { + setSelectedId(defaultService.id); + setForm(toEditable(defaultService)); + } else { + setSelectedId(""); + setForm(newServiceTemplate()); + } + }; + + useEffect(() => { + const run = async () => { + try { + await loadServices(); + } catch (error) { + console.error("Failed to load email services:", error); + } finally { + setLoading(false); + } + }; + + run(); + }, []); + + const setField = (key: K, value: EditableService[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSelect = async (id: string) => { + try { + await cmsApi.selectOrganizationEmailService(id); + await loadServices(); + alert("Servicio por defecto actualizado."); + } catch (error) { + console.error("Failed to select email service:", error); + alert("No se pudo seleccionar el servicio por defecto."); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("¿Eliminar este servicio SMTP?")) return; + + try { + await cmsApi.deleteOrganizationEmailService(id); + await loadServices(); + setSmtpPassword(""); + alert("Servicio eliminado."); + } catch (error) { + console.error("Failed to delete email service:", error); + alert("No se pudo eliminar el servicio."); + } + }; + + const handleNew = () => { + setSelectedId(""); + setForm(newServiceTemplate()); + setSmtpPassword(""); + }; + + const toPayload = (): UpsertOrganizationEmailServicePayload => ({ + service_type: "smtp", + provider_key: (form.provider_key || "custom").trim().toLowerCase(), + display_name: form.display_name.trim() || "Servicio SMTP", + smtp_enabled: form.smtp_enabled, + is_default: form.is_default, + smtp_host: form.smtp_host.trim() || undefined, + smtp_port: Number(form.smtp_port) || 587, + smtp_from: form.smtp_from.trim() || undefined, + smtp_username: form.smtp_username.trim() || undefined, + smtp_starttls: form.smtp_starttls, + ...(smtpPassword.trim() ? { smtp_password: smtpPassword.trim() } : {}), + }); + + const handleSave = async () => { + setSaving(true); + try { + const payload = toPayload(); + if (form.id) { + await cmsApi.updateOrganizationEmailService(form.id, payload); + } else { + await cmsApi.createOrganizationEmailService(payload); + } + await loadServices(); + setSmtpPassword(""); + alert("Servicio SMTP guardado correctamente."); + } catch (error) { + console.error("Failed to save email service:", error); + alert("No se pudo guardar el servicio SMTP."); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Cargando servicios de correo...
; + } + + return ( +
+ + Servicios de Correo por Empresa + + +

+ Cada empresa puede registrar varios servicios de correo (SMTP) y seleccionar cuál usar como predeterminado. + Si no hay registros, se crea uno automáticamente con valores de entorno. +

+ +
+ {services.map((svc) => ( +
+
+
+

{svc.display_name}

+

+ provider: {svc.provider_key} · {svc.smtp_enabled ? "Activo" : "Inactivo"} +

+
+ {svc.is_default && ( + + Predeterminado + + )} +
+ +
+ + {!svc.is_default && ( + + )} + {!svc.is_default && services.length > 1 && ( + + )} +
+
+ ))} +
+ +
+ +
+ +
+

+ {form.id ? "Editar servicio" : "Crear servicio"} +

+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + {selectedService && ( +

+ Editando: {selectedService.display_name} +

+ )} +
+ ); +} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 3bfe78a..17a8f8e 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -302,6 +302,36 @@ export interface OrganizationSSOConfig { updated_at: string; } +export interface OrganizationEmailService { + id: string; + organization_id: string; + service_type: string; + provider_key: string; + display_name: string; + smtp_enabled: boolean; + is_default: boolean; + smtp_host?: string; + smtp_port: number; + smtp_from?: string; + smtp_username?: string; + smtp_starttls: boolean; + has_password: boolean; +} + +export interface UpsertOrganizationEmailServicePayload { + service_type: string; + provider_key: string; + display_name: string; + smtp_enabled: boolean; + is_default: boolean; + smtp_host?: string; + smtp_port: number; + smtp_from?: string; + smtp_username?: string; + smtp_password?: string; + smtp_starttls: boolean; +} + export interface ProvisionPayload { org_name: string; org_domain?: string; @@ -881,6 +911,18 @@ export const cmsApi = { }, getSSOConfig: (): Promise => apiFetch('/organization/sso'), updateSSOConfig: (payload: Partial): Promise => apiFetch('/organization/sso', { method: 'PUT', body: JSON.stringify(payload) }), + getOrganizationEmailSettings: (): Promise => apiFetch('/organization/email-settings'), + updateOrganizationEmailSettings: (payload: UpsertOrganizationEmailServicePayload): Promise => + apiFetch('/organization/email-settings', { method: 'PUT', body: JSON.stringify(payload) }), + listOrganizationEmailServices: (): Promise => apiFetch('/organization/email-services'), + createOrganizationEmailService: (payload: UpsertOrganizationEmailServicePayload): Promise => + apiFetch('/organization/email-services', { method: 'POST', body: JSON.stringify(payload) }), + updateOrganizationEmailService: (id: string, payload: UpsertOrganizationEmailServicePayload): Promise => + apiFetch(`/organization/email-services/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteOrganizationEmailService: (id: string): Promise => + apiFetch(`/organization/email-services/${id}`, { method: 'DELETE' }), + selectOrganizationEmailService: (id: string): Promise => + apiFetch(`/organization/email-services/${id}/select`, { method: 'POST' }), getOrganizationExerciseSettings: (): Promise => apiFetch('/organization/exercise-settings'), updateOrganizationExerciseSettings: (payload: Omit): Promise => apiFetch('/organization/exercise-settings', { method: 'PUT', body: JSON.stringify(payload) }),