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).
|
/// Number of random blobs to sample-check (default: 100).
|
||||||
pub sample_size: Option<usize>,
|
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>,
|
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.
|
/// Port for sending transactional email.
|
||||||
///
|
///
|
||||||
/// Implementations must:
|
/// Implementations must:
|
||||||
@@ -54,10 +70,12 @@ pub struct EmailMessage {
|
|||||||
/// `dyn` patterns at the service boundary).
|
/// `dyn` patterns at the service boundary).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EmailSender: Send + Sync + 'static {
|
pub trait EmailSender: Send + Sync + 'static {
|
||||||
/// Send one message. Returns `Ok(())` only after the SMTP server has
|
/// Send one message. Returns `Ok(outcome)` only after the SMTP server
|
||||||
/// accepted the message (i.e. after the final `.` or LMTP DATA close).
|
/// 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
|
/// Caller may run this fire-and-forget via `tokio::spawn` if response
|
||||||
/// timing matters (e.g. magic-link invite path defending against
|
/// timing matters (e.g. magic-link invite path defending against
|
||||||
/// enumeration via latency).
|
/// enumeration via latency); the outcome is then logged-only.
|
||||||
async fn send(&self, message: EmailMessage) -> Result<(), DomainError>;
|
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::transport::smtp::authentication::Credentials;
|
||||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
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::config::{SmtpConfig, SmtpTlsMode};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ impl SmtpEmailSender {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EmailSender for SmtpEmailSender {
|
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| {
|
let to: Mailbox = message.to.parse().map_err(|e| {
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
crate::common::errors::ErrorKind::InvalidInput,
|
crate::common::errors::ErrorKind::InvalidInput,
|
||||||
@@ -132,11 +132,23 @@ impl EmailSender for SmtpEmailSender {
|
|||||||
DomainError::internal_error("SmtpEmailSender", format!("build message: {}", e))
|
DomainError::internal_error("SmtpEmailSender", format!("build message: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
self.transport
|
let response =
|
||||||
.send(built)
|
self.transport.send(built).await.map_err(|e| {
|
||||||
.await
|
DomainError::internal_error("SmtpEmailSender", format!("send: {}", e))
|
||||||
.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::{
|
use crate::application::dtos::settings_dto::{
|
||||||
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
|
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
|
||||||
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, StartMigrationDto,
|
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
|
||||||
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
|
||||||
UpdateUserRoleDto, VerifyMigrationDto,
|
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
|
||||||
};
|
};
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
@@ -58,6 +58,9 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/settings/registration", put(set_registration_setting))
|
.route("/settings/registration", put(set_registration_setting))
|
||||||
// Audio metadata
|
// Audio metadata
|
||||||
.route("/audio/metadata/reextract", post(reextract_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).
|
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||||
@@ -1208,3 +1211,154 @@ async fn reextract_audio_metadata(
|
|||||||
"failed": result.failed,
|
"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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
<button class="admin-tab" id="tab-btn-storage">
|
<button class="admin-tab" id="tab-btn-storage">
|
||||||
<i class="fas fa-database"></i> <span data-i18n="admin.tab_storage">Storage</span>
|
<i class="fas fa-database"></i> <span data-i18n="admin.tab_storage">Storage</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="admin-tab" id="tab-btn-smtp">
|
||||||
|
<i class="fas fa-envelope"></i> <span data-i18n="admin.tab_smtp">SMTP</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-dashboard" class="tab-content active">
|
<div id="tab-dashboard" class="tab-content active">
|
||||||
@@ -609,6 +612,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ════════ SMTP tab ════════ -->
|
||||||
|
<div id="tab-smtp" class="tab-content">
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-envelope"></i> <span data-i18n="admin.smtp_title">Outbound Email (SMTP)</span>
|
||||||
|
</h2>
|
||||||
|
<p class="muted" data-i18n="admin.smtp_intro">
|
||||||
|
SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="smtp-info-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="admin.smtp_enabled_label">Status</th>
|
||||||
|
<td id="smtp-enabled">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OXICLOUD_SMTP_HOST</th>
|
||||||
|
<td id="smtp-host">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OXICLOUD_SMTP_PORT</th>
|
||||||
|
<td id="smtp-port">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OXICLOUD_SMTP_TLS</th>
|
||||||
|
<td id="smtp-tls">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OXICLOUD_SMTP_FROM</th>
|
||||||
|
<td id="smtp-from">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>OXICLOUD_SMTP_USER</th>
|
||||||
|
<td id="smtp-user-state">—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="mt-14">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span data-i18n="admin.smtp_test_title">Send a test email</span>
|
||||||
|
</h3>
|
||||||
|
<p class="muted" data-i18n="admin.smtp_test_intro">
|
||||||
|
Sends a hardcoded diagnostic message to the recipient below and reports the SMTP server's response so you can correlate it with your relay logs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-test-to" data-i18n="admin.smtp_test_to">Recipient address</label>
|
||||||
|
<input
|
||||||
|
id="smtp-test-to"
|
||||||
|
type="email"
|
||||||
|
placeholder="alice@example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-smtp-test" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span data-i18n="admin.smtp_send_test">Send test email</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="smtp-test-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -970,6 +970,45 @@ details[open] summary {
|
|||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Simple two-column "key: value" table used by the SMTP admin panel.
|
||||||
|
Designed for the SMTP-info read-only view where the values
|
||||||
|
(hostnames, full mailboxes, status strings) are too long for the
|
||||||
|
centered stat-card layout above. */
|
||||||
|
.smtp-info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.smtp-info-table th,
|
||||||
|
.smtp-info-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.smtp-info-table tr:last-child th,
|
||||||
|
.smtp-info-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.smtp-info-table th {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
width: 220px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.smtp-info-table td {
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.storage-backend-selector {
|
.storage-backend-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function switchTab(name, el) {
|
|||||||
if (name === 'users') loadUsers();
|
if (name === 'users') loadUsers();
|
||||||
if (name === 'dashboard') loadDashboard();
|
if (name === 'dashboard') loadDashboard();
|
||||||
if (name === 'storage') loadStorage();
|
if (name === 'storage') loadStorage();
|
||||||
|
if (name === 'smtp') loadSmtp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
@@ -1158,6 +1159,110 @@ function showAccessDenied() {
|
|||||||
showElement('access-denied');
|
showElement('access-denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── SMTP tab ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the runtime SMTP info and render the read-only status grid.
|
||||||
|
* Configuration is sourced exclusively from `OXICLOUD_SMTP_*` env vars;
|
||||||
|
* this view is purely diagnostic — no save path exists.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function loadSmtp() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API}/admin/smtp/info`, {
|
||||||
|
headers: headers(),
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
/** @type {{enabled: boolean, host: string, port: number, tls: string, from: string, user_state: string}} */
|
||||||
|
const info = await resp.json();
|
||||||
|
|
||||||
|
const enabledEl = document.getElementById('smtp-enabled');
|
||||||
|
if (enabledEl) {
|
||||||
|
enabledEl.textContent = info.enabled ? i18n.t('admin.smtp_enabled') || 'Enabled' : i18n.t('admin.smtp_disabled') || 'Disabled (host unset)';
|
||||||
|
enabledEl.style.color = info.enabled ? 'var(--success)' : 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
const setText = (/** @type {string} */ id, /** @type {string} */ value) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value || '—';
|
||||||
|
};
|
||||||
|
setText('smtp-host', info.host);
|
||||||
|
setText('smtp-port', String(info.port));
|
||||||
|
setText('smtp-tls', info.tls);
|
||||||
|
setText('smtp-from', info.from);
|
||||||
|
setText('smtp-user-state', info.user_state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load SMTP info', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a diagnostic test email through the configured SMTP relay.
|
||||||
|
* Backend always responds with 200 carrying `{success, code, message,
|
||||||
|
* error}` — SMTP-level failures are operational data, not HTTP errors.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function sendSmtpTest() {
|
||||||
|
const input = /** @type {HTMLInputElement | null} */ (document.getElementById('smtp-test-to'));
|
||||||
|
const resultEl = document.getElementById('smtp-test-result');
|
||||||
|
const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-smtp-test'));
|
||||||
|
if (!input || !resultEl) return;
|
||||||
|
|
||||||
|
const to = input.value.trim();
|
||||||
|
if (!to) {
|
||||||
|
resultEl.className = 'alert alert-error';
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.textContent = i18n.t('admin.smtp_test_missing_to') || 'Enter a recipient address.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
resultEl.className = 'alert alert-info';
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
resultEl.textContent = i18n.t('admin.smtp_sending') || 'Sending…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API}/admin/smtp/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ to })
|
||||||
|
});
|
||||||
|
if (resp.status === 503) {
|
||||||
|
resultEl.className = 'alert alert-error';
|
||||||
|
resultEl.textContent = i18n.t('admin.smtp_not_configured') || 'SMTP is not configured on this server.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
resultEl.className = 'alert alert-error';
|
||||||
|
resultEl.textContent = `HTTP ${resp.status}: ${await resp.text()}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/** @type {{success: boolean, code?: number, message?: string, error?: string}} */
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
resultEl.className = 'alert alert-success';
|
||||||
|
const codeLabel = i18n.t('admin.smtp_server_code') || 'Server replied';
|
||||||
|
resultEl.innerHTML =
|
||||||
|
`<strong>${escapeHtml(i18n.t('admin.smtp_sent') || 'Test email sent.')}</strong><br>` +
|
||||||
|
`${escapeHtml(codeLabel)}: <code>${data.code ?? ''} ${escapeHtml(data.message ?? '')}</code>`;
|
||||||
|
} else {
|
||||||
|
resultEl.className = 'alert alert-error';
|
||||||
|
const failLabel = i18n.t('admin.smtp_send_failed') || 'Send failed.';
|
||||||
|
resultEl.innerHTML = `<strong>${escapeHtml(failLabel)}</strong><br>` + `<code>${escapeHtml(data.error ?? 'unknown error')}</code>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
resultEl.className = 'alert alert-error';
|
||||||
|
resultEl.textContent = i18n.t('admin.error_network', {
|
||||||
|
message: /** @type {Error} */ (e).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Apply i18n when translations load / change ── */
|
/* ── Apply i18n when translations load / change ── */
|
||||||
document.addEventListener('translationsLoaded', () => {
|
document.addEventListener('translationsLoaded', () => {
|
||||||
i18n.translatePage();
|
i18n.translatePage();
|
||||||
@@ -1186,6 +1291,11 @@ document.getElementById('tab-btn-oidc').addEventListener('click', function () {
|
|||||||
document.getElementById('tab-btn-storage').addEventListener('click', function () {
|
document.getElementById('tab-btn-storage').addEventListener('click', function () {
|
||||||
switchTab('storage', this);
|
switchTab('storage', this);
|
||||||
});
|
});
|
||||||
|
document.getElementById('tab-btn-smtp').addEventListener('click', function () {
|
||||||
|
switchTab('smtp', this);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest);
|
||||||
|
|
||||||
document.getElementById('ds-registration').addEventListener('change', function () {
|
document.getElementById('ds-registration').addEventListener('change', function () {
|
||||||
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
|
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
|
||||||
|
|||||||
+17
-1
@@ -649,7 +649,23 @@
|
|||||||
"migration_verify_passed": "Verification passed",
|
"migration_verify_passed": "Verification passed",
|
||||||
"migration_verify_failed": "Verification failed",
|
"migration_verify_failed": "Verification failed",
|
||||||
"migration_failed_blobs": "failed blobs",
|
"migration_failed_blobs": "failed blobs",
|
||||||
"testing": "Testing…"
|
"testing": "Testing…",
|
||||||
|
"tab_smtp": "SMTP",
|
||||||
|
"smtp_title": "Outbound Email (SMTP)",
|
||||||
|
"smtp_intro": "SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.",
|
||||||
|
"smtp_enabled_label": "Status",
|
||||||
|
"smtp_enabled": "Enabled",
|
||||||
|
"smtp_disabled": "Disabled (host unset)",
|
||||||
|
"smtp_test_title": "Send a test email",
|
||||||
|
"smtp_test_intro": "Sends a hardcoded diagnostic message to the recipient below and reports the SMTP server's response so you can correlate it with your relay logs.",
|
||||||
|
"smtp_test_to": "Recipient address",
|
||||||
|
"smtp_send_test": "Send test email",
|
||||||
|
"smtp_sending": "Sending…",
|
||||||
|
"smtp_sent": "Test email sent.",
|
||||||
|
"smtp_send_failed": "Send failed.",
|
||||||
|
"smtp_server_code": "Server replied",
|
||||||
|
"smtp_test_missing_to": "Enter a recipient address.",
|
||||||
|
"smtp_not_configured": "SMTP is not configured on this server."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"page_title": "Profile",
|
"page_title": "Profile",
|
||||||
|
|||||||
+17
-1
@@ -649,7 +649,23 @@
|
|||||||
"migration_verify_passed": "Vérification réussie",
|
"migration_verify_passed": "Vérification réussie",
|
||||||
"migration_verify_failed": "Échec de la vérification",
|
"migration_verify_failed": "Échec de la vérification",
|
||||||
"migration_failed_blobs": "Blobs échoués",
|
"migration_failed_blobs": "Blobs échoués",
|
||||||
"testing": "Test en cours..."
|
"testing": "Test en cours...",
|
||||||
|
"tab_smtp": "SMTP",
|
||||||
|
"smtp_title": "E-mail sortant (SMTP)",
|
||||||
|
"smtp_intro": "Le SMTP est configuré exclusivement via les variables d'environnement (OXICLOUD_SMTP_*). Les valeurs ci-dessous proviennent du serveur en cours d'exécution — pour les modifier, éditez l'environnement et redémarrez OxiCloud.",
|
||||||
|
"smtp_enabled_label": "État",
|
||||||
|
"smtp_enabled": "Activé",
|
||||||
|
"smtp_disabled": "Désactivé (hôte non défini)",
|
||||||
|
"smtp_test_title": "Envoyer un e-mail de test",
|
||||||
|
"smtp_test_intro": "Envoie un message de diagnostic au destinataire ci-dessous et affiche la réponse du serveur SMTP afin que vous puissiez la corréler avec les journaux de votre relais.",
|
||||||
|
"smtp_test_to": "Adresse du destinataire",
|
||||||
|
"smtp_send_test": "Envoyer l'e-mail de test",
|
||||||
|
"smtp_sending": "Envoi…",
|
||||||
|
"smtp_sent": "E-mail de test envoyé.",
|
||||||
|
"smtp_send_failed": "Échec de l'envoi.",
|
||||||
|
"smtp_server_code": "Le serveur a répondu",
|
||||||
|
"smtp_test_missing_to": "Veuillez saisir une adresse de destinataire.",
|
||||||
|
"smtp_not_configured": "Le SMTP n'est pas configuré sur ce serveur."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"page_title": "Profil",
|
"page_title": "Profil",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// OxiCloud Service Worker
|
// OxiCloud Service Worker
|
||||||
// FIXME: generate cache name according build ?
|
// FIXME: generate cache name according build ?
|
||||||
const CACHE_NAME = 'oxicloud-cache-v23';
|
const CACHE_NAME = 'oxicloud-cache-v24';
|
||||||
|
|
||||||
// Only cache static assets — NOT HTML files.
|
// Only cache static assets — NOT HTML files.
|
||||||
// HTML files are served network-first so browsers always get the latest
|
// HTML files are served network-first so browsers always get the latest
|
||||||
|
|||||||
Reference in New Issue
Block a user