diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 0154cc07..894b52e3 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -229,3 +229,55 @@ pub struct VerifyMigrationDto { /// Number of random blobs to sample-check (default: 100). pub sample_size: Option, } + +// ============================================================================ +// 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, + /// `` if a SASL user is configured, `` 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, + /// First line of the SMTP server's reply. Only set on success. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + /// Human-readable error message. Only set on failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/src/application/ports/email_sender.rs b/src/application/ports/email_sender.rs index 9f9e3a4b..ecc129ec 100644 --- a/src/application/ports/email_sender.rs +++ b/src/application/ports/email_sender.rs @@ -39,6 +39,22 @@ pub struct EmailMessage { pub html_body: Option, } +/// 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; } diff --git a/src/infrastructure/services/smtp_email_sender.rs b/src/infrastructure/services/smtp_email_sender.rs index bc25ed47..e8d95dc9 100644 --- a/src/infrastructure/services/smtp_email_sender.rs +++ b/src/infrastructure/services/smtp_email_sender.rs @@ -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 { 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`; 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 }) } } diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index aa397252..bc7accda 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -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> { .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>, + headers: HeaderMap, +) -> Result { + 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() { + "" + } else { + "" + }, + }; + + 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>, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + 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)) +} diff --git a/static/admin.html b/static/admin.html index bc82c574..7ef8639a 100644 --- a/static/admin.html +++ b/static/admin.html @@ -61,6 +61,9 @@ +
@@ -609,6 +612,70 @@
+ + +
+
+

+ Outbound Email (SMTP) +

+

+ 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. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status—
OXICLOUD_SMTP_HOST—
OXICLOUD_SMTP_PORT—
OXICLOUD_SMTP_TLS—
OXICLOUD_SMTP_FROM—
OXICLOUD_SMTP_USER—
+ +

+ Send a test email +

+

+ 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. +

+ +
+ + +
+ + + + +
+
diff --git a/static/css/views/admin.css b/static/css/views/admin.css index ad2b42d4..66aab0ac 100644 --- a/static/css/views/admin.css +++ b/static/css/views/admin.css @@ -970,6 +970,45 @@ details[open] summary { 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 { display: flex; gap: 12px; diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 3e0b92ed..675648ae 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -154,6 +154,7 @@ function switchTab(name, el) { if (name === 'users') loadUsers(); if (name === 'dashboard') loadDashboard(); if (name === 'storage') loadStorage(); + if (name === 'smtp') loadSmtp(); } async function loadDashboard() { @@ -1158,6 +1159,110 @@ function showAccessDenied() { 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} + */ +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} + */ +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 = + `${escapeHtml(i18n.t('admin.smtp_sent') || 'Test email sent.')}
` + + `${escapeHtml(codeLabel)}: ${data.code ?? ''} ${escapeHtml(data.message ?? '')}`; + } else { + resultEl.className = 'alert alert-error'; + const failLabel = i18n.t('admin.smtp_send_failed') || 'Send failed.'; + resultEl.innerHTML = `${escapeHtml(failLabel)}
` + `${escapeHtml(data.error ?? 'unknown error')}`; + } + } 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 ── */ document.addEventListener('translationsLoaded', () => { i18n.translatePage(); @@ -1186,6 +1291,11 @@ document.getElementById('tab-btn-oidc').addEventListener('click', function () { document.getElementById('tab-btn-storage').addEventListener('click', function () { 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 () { toggleRegistration(/** @type {HTMLInputElement} */ (this).checked); diff --git a/static/locales/en.json b/static/locales/en.json index 41ef9d7e..38eec795 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -649,7 +649,23 @@ "migration_verify_passed": "Verification passed", "migration_verify_failed": "Verification failed", "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": { "page_title": "Profile", diff --git a/static/locales/fr.json b/static/locales/fr.json index 6a866c3c..1d96c7c1 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -649,7 +649,23 @@ "migration_verify_passed": "Vérification réussie", "migration_verify_failed": "Échec de la vérification", "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": { "page_title": "Profil", diff --git a/static/sw.js b/static/sw.js index f1091955..99840ea2 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // 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. // HTML files are served network-first so browsers always get the latest