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.
This commit is contained in:
2026-04-15 09:33:50 -04:00
parent 44facf7f4a
commit e1d5975e57
12 changed files with 1877 additions and 18 deletions
@@ -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);
@@ -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;
@@ -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<String>,
smtp_port: i32,
smtp_from: Option<String>,
smtp_username: Option<String>,
smtp_password: Option<String>,
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<String>,
pub smtp_port: i32,
pub smtp_from: Option<String>,
pub smtp_username: Option<String>,
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<String>,
pub smtp_port: i32,
pub smtp_from: Option<String>,
pub smtp_username: Option<String>,
pub smtp_password: Option<String>,
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<String>) -> Option<String> {
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::<i32>().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<PgPool>,
) -> Result<Json<Vec<OrganizationEmailServiceResponse>>, (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<PgPool>,
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
) -> Result<Json<OrganizationEmailServiceResponse>, (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<PgPool>,
Path(id): Path<Uuid>,
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
) -> Result<Json<OrganizationEmailServiceResponse>, (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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
) -> Result<Json<OrganizationEmailServiceResponse>, (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<PgPool>,
Json(payload): Json<UpsertOrganizationEmailServicePayload>,
) -> Result<Json<OrganizationEmailServiceResponse>, (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
}
+27 -7
View File
@@ -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",
+1
View File
@@ -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"] }
@@ -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<String>,
}
#[derive(Debug, Clone, sqlx::FromRow)]
struct OrganizationEmailSettingsRow {
service_type: String,
smtp_enabled: bool,
smtp_host: Option<String>,
smtp_port: i32,
smtp_from: Option<String>,
smtp_username: Option<String>,
smtp_password: Option<String>,
smtp_starttls: bool,
}
#[derive(Debug, Clone)]
struct SmtpConfig {
enabled: bool,
host: String,
from: String,
port: u16,
starttls: bool,
username: Option<String>,
password: Option<String>,
}
fn parse_bool(value: &str) -> bool {
let normalized = value.trim().to_lowercase();
normalized == "1" || normalized == "true" || normalized == "yes"
}
fn load_env_smtp_config() -> Result<SmtpConfig, String> {
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 <no-reply@openccb.local>".to_string());
let port = env::var("SMTP_PORT")
.ok()
.and_then(|v| v.parse::<u16>().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<SmtpConfig> {
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 <no-reply@openccb.local>".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<AsyncSmtpTransport<Tokio1Executor>, String> {
let mut builder = if config.starttls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
.map_err(|e| format!("Error al crear relay SMTP con STARTTLS: {}", e))?
.port(config.port)
} else {
AsyncSmtpTransport::<Tokio1Executor>::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<PgPool>,
Json(payload): Json<CreateThreadPayload>,
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
let author_name: Option<String> = 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<String>)>(
"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::<Vec<_>>();
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<Json<DiscussionPost>, (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<String> = 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<String>)>(
"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::<Vec<_>>();
// 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<String>)>(
"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))
}
+7 -7
View File
@@ -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()