feat(smtp): add a precious SMTP test for admin only
This commit is contained in:
@@ -229,3 +229,55 @@ pub struct VerifyMigrationDto {
|
||||
/// Number of random blobs to sample-check (default: 100).
|
||||
pub sample_size: Option<usize>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SMTP Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Read-only SMTP info shown on the admin SMTP page. SMTP configuration
|
||||
/// is sourced exclusively from environment variables — these fields are
|
||||
/// for display only and any change has to happen by updating the env
|
||||
/// and restarting the server.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SmtpInfoDto {
|
||||
/// Whether `OXICLOUD_SMTP_HOST` is set and SMTP construction succeeded.
|
||||
pub enabled: bool,
|
||||
/// `OXICLOUD_SMTP_HOST`. Empty string when unset.
|
||||
pub host: String,
|
||||
/// `OXICLOUD_SMTP_PORT`. Default 587.
|
||||
pub port: u16,
|
||||
/// Transport encryption mode: `"starttls"`, `"tls"`, or `"none"`.
|
||||
pub tls: String,
|
||||
/// `OXICLOUD_SMTP_FROM` mailbox. Empty when unset.
|
||||
pub from: String,
|
||||
/// `<set>` if a SASL user is configured, `<anon>` otherwise.
|
||||
/// Never echoes the username — admins compare against the
|
||||
/// runtime config without having to look in `.env`.
|
||||
pub user_state: &'static str,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/admin/smtp/test`: send a hardcoded
|
||||
/// diagnostic email to the given recipient.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SendSmtpTestDto {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Result of a `POST /api/admin/smtp/test` invocation. `success=true`
|
||||
/// carries the SMTP server's response code + first reply line; on
|
||||
/// failure the relevant error message goes in `error`. Always 200 OK
|
||||
/// so the frontend can render both outcomes in one place — the SMTP
|
||||
/// failure is a normal operational state, not an HTTP error.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SmtpTestResultDto {
|
||||
pub success: bool,
|
||||
/// SMTP status code (e.g. 250). Only set on success.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<u16>,
|
||||
/// First line of the SMTP server's reply. Only set on success.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
/// Human-readable error message. Only set on failure.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ pub struct EmailMessage {
|
||||
pub html_body: Option<String>,
|
||||
}
|
||||
|
||||
/// What the SMTP server said when it accepted the message. Surfaced
|
||||
/// through the trait so the admin "test email" endpoint can show the
|
||||
/// response to operators; the invitation flow generally ignores it but
|
||||
/// logs it via `tracing`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmailSendOutcome {
|
||||
/// SMTP status code from the final response (e.g. `250` for "OK").
|
||||
/// Encoded as a `u16` because that's the natural range; lettre
|
||||
/// returns it as a structured enum and we collapse it here.
|
||||
pub code: u16,
|
||||
/// First line of the server's reply (e.g. `"2.0.0 OK"`, or the
|
||||
/// upstream provider's queue-id banner). Best-effort; if the
|
||||
/// response was empty (unusual) this is the empty string.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Port for sending transactional email.
|
||||
///
|
||||
/// Implementations must:
|
||||
@@ -54,10 +70,12 @@ pub struct EmailMessage {
|
||||
/// `dyn` patterns at the service boundary).
|
||||
#[async_trait]
|
||||
pub trait EmailSender: Send + Sync + 'static {
|
||||
/// Send one message. Returns `Ok(())` only after the SMTP server has
|
||||
/// accepted the message (i.e. after the final `.` or LMTP DATA close).
|
||||
/// Send one message. Returns `Ok(outcome)` only after the SMTP server
|
||||
/// has accepted the message (i.e. after the final `.` or LMTP DATA
|
||||
/// close). The outcome carries the SMTP response code + first line
|
||||
/// so diagnostic surfaces (admin "test email" page) can show it.
|
||||
/// Caller may run this fire-and-forget via `tokio::spawn` if response
|
||||
/// timing matters (e.g. magic-link invite path defending against
|
||||
/// enumeration via latency).
|
||||
async fn send(&self, message: EmailMessage) -> Result<(), DomainError>;
|
||||
/// enumeration via latency); the outcome is then logged-only.
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError>;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
|
||||
use crate::common::config::{SmtpConfig, SmtpTlsMode};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
@@ -92,7 +92,7 @@ impl SmtpEmailSender {
|
||||
|
||||
#[async_trait]
|
||||
impl EmailSender for SmtpEmailSender {
|
||||
async fn send(&self, message: EmailMessage) -> Result<(), DomainError> {
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
|
||||
let to: Mailbox = message.to.parse().map_err(|e| {
|
||||
DomainError::new(
|
||||
crate::common::errors::ErrorKind::InvalidInput,
|
||||
@@ -132,11 +132,23 @@ impl EmailSender for SmtpEmailSender {
|
||||
DomainError::internal_error("SmtpEmailSender", format!("build message: {}", e))
|
||||
})?;
|
||||
|
||||
self.transport
|
||||
.send(built)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("SmtpEmailSender", format!("send: {}", e)))?;
|
||||
let response =
|
||||
self.transport.send(built).await.map_err(|e| {
|
||||
DomainError::internal_error("SmtpEmailSender", format!("send: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
// Lettre's `Response::code()` returns a structured `Code`; its
|
||||
// `Display` impl is the three-digit form ("250", "451", …).
|
||||
let code: u16 = response.code().to_string().parse().unwrap_or(0);
|
||||
// `message()` is `Iterator<Item = &String>`; take the first
|
||||
// line (the rest are typically multi-line EHLO continuations,
|
||||
// not interesting for a confirmation).
|
||||
let message = response
|
||||
.message()
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(EmailSendOutcome { code, message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ use axum::{
|
||||
|
||||
use crate::application::dtos::settings_dto::{
|
||||
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
|
||||
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, StartMigrationDto,
|
||||
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
||||
UpdateUserRoleDto, VerifyMigrationDto,
|
||||
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
|
||||
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
|
||||
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -58,6 +58,9 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route("/settings/registration", put(set_registration_setting))
|
||||
// Audio metadata
|
||||
.route("/audio/metadata/reextract", post(reextract_audio_metadata))
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
@@ -1208,3 +1211,154 @@ async fn reextract_audio_metadata(
|
||||
"failed": result.failed,
|
||||
})))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// SMTP diagnostics
|
||||
// ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The SMTP backend is configured exclusively via OXICLOUD_SMTP_* env
|
||||
// vars (see docs/config/env.md). The admin UI uses these two endpoints
|
||||
// purely for diagnostics:
|
||||
// - `get_smtp_info` shows the current runtime config (read-only — no
|
||||
// write endpoint exists; operators edit `.env` and restart).
|
||||
// - `send_smtp_test` sends a hardcoded confirmation mail to a
|
||||
// recipient supplied by the admin, returning the SMTP server's
|
||||
// response so the operator can correlate it with their relay logs.
|
||||
|
||||
/// GET /api/admin/smtp/info — read-only view of the running SMTP config.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/admin/smtp/info",
|
||||
responses(
|
||||
(status = 200, description = "Current SMTP settings", body = SmtpInfoDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn get_smtp_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let smtp = &state.core.config.smtp;
|
||||
let info = SmtpInfoDto {
|
||||
enabled: smtp.is_enabled() && state.email_sender.is_some(),
|
||||
host: smtp.host.clone(),
|
||||
port: smtp.port,
|
||||
tls: match smtp.tls {
|
||||
crate::common::config::SmtpTlsMode::Starttls => "starttls".to_string(),
|
||||
crate::common::config::SmtpTlsMode::Tls => "tls".to_string(),
|
||||
crate::common::config::SmtpTlsMode::None => "none".to_string(),
|
||||
},
|
||||
from: smtp.from.clone(),
|
||||
user_state: if smtp.user.is_empty() {
|
||||
"<anon>"
|
||||
} else {
|
||||
"<set>"
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`.
|
||||
///
|
||||
/// Returns 200 regardless of SMTP outcome; the body's `success` flag
|
||||
/// + `code`/`message` (or `error`) tell the frontend what to render.
|
||||
/// This keeps SMTP-level failures (4xx/5xx replies, connection
|
||||
/// timeouts) as ordinary diagnostic data rather than HTTP errors.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/smtp/test",
|
||||
request_body = SendSmtpTestDto,
|
||||
responses(
|
||||
(status = 200, description = "Send attempt completed", body = SmtpTestResultDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 503, description = "SMTP not configured"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn send_smtp_test(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<SendSmtpTestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
|
||||
let recipient = dto.to.trim().to_string();
|
||||
if recipient.is_empty() {
|
||||
return Err(AppError::bad_request("Recipient address is required"));
|
||||
}
|
||||
|
||||
let sender = state.email_sender.as_ref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"SMTP is not configured (set OXICLOUD_SMTP_HOST in .env to enable)",
|
||||
"ServiceUnavailable",
|
||||
)
|
||||
})?;
|
||||
|
||||
let message = crate::application::ports::email_sender::EmailMessage {
|
||||
to: recipient.clone(),
|
||||
subject: "OxiCloud SMTP test".to_string(),
|
||||
text_body: format!(
|
||||
"This is a diagnostic message sent from your OxiCloud instance.\n\
|
||||
\n\
|
||||
If you are reading this, your SMTP relay accepted the message — \
|
||||
outbound email is wired up correctly.\n\
|
||||
\n\
|
||||
Triggered by admin user id {} on {}.\n",
|
||||
admin_id,
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
),
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
);
|
||||
|
||||
let result = match sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send_ok",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
code = outcome.code,
|
||||
message = %outcome.message,
|
||||
);
|
||||
SmtpTestResultDto {
|
||||
success: true,
|
||||
code: Some(outcome.code),
|
||||
message: Some(outcome.message),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send_failed",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
error = %e.message,
|
||||
);
|
||||
SmtpTestResultDto {
|
||||
success: false,
|
||||
code: None,
|
||||
message: None,
|
||||
error: Some(e.message),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user