fix(email): test saved SMTP config and add TLS modes (#3388)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:30:00 +02:00
1 parent 45b78a13c2
commit cca8227ca6
66 files changed
+2464 -319

No files matched your search

+3 -4
View File
@@ -4,7 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -34,11 +34,10 @@ impl AdminApiClient {
.await
}
pub async fn test_instance_smtp_config(
pub async fn test_saved_instance_smtp_config(
&self,
request: &InstanceEmailSmtpTestRequest,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
self.post_typed("/admin/instance/config/smtp-tests", request)
self.post_typed("/admin/instance/config/smtp-tests", &serde_json::json!({}))
.await
}
+3 -11
View File
@@ -229,7 +229,8 @@ pub struct InstanceEmailSmtpIntegrationResponse {
pub username: Option<String>,
#[serde(default)]
pub password_set: bool,
pub secure: Option<bool>,
pub tls_mode: Option<String>,
pub effective_tls_mode: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -873,7 +874,7 @@ pub struct InstanceEmailSmtpIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
pub tls_mode: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -928,15 +929,6 @@ pub struct InstanceAttachmentDecayUpdateRequest {
pub renew_window_days: Option<u32>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceEmailSmtpTestRequest {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub secure: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceEmailSmtpTestResponse {
#[serde(default)]
+72 -40
View File
@@ -14,7 +14,7 @@ use crate::{
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
@@ -233,21 +233,19 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"test_smtp" => match build_smtp_test_request(&form) {
Ok(request) => match client.test_instance_smtp_config(&request).await {
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
Ok(response) => FlashData::error(
response
.error
.unwrap_or_else(|| "SMTP validation failed".to_owned()),
),
Err(error) => {
tracing::warn!(%error, "admin API request failed: test SMTP config");
FlashData::error("Failed to validate SMTP configuration")
}
},
Err(message) => FlashData::error(message),
},
"test_smtp" => {
let update = build_integrations_update(&form);
match client.update_instance_config(&update).await {
Ok(_) => match client.test_saved_instance_smtp_config().await {
Ok(response) => smtp_test_flash(response),
Err(error) => {
tracing::warn!(%error, "admin API request failed: test SMTP config");
FlashData::error("Settings saved, but the SMTP test could not run")
}
},
Err(error) => instance_config_result(Err::<(), _>(error)),
}
}
"disable_single_community" => {
let update = build_single_community_update(false);
instance_config_result(client.update_instance_config(&update).await)
@@ -420,6 +418,18 @@ fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) ->
}
}
fn smtp_test_flash(response: InstanceEmailSmtpTestResponse) -> FlashData {
if response.ok {
return FlashData::success("Settings saved and SMTP connection verified");
}
let reason = response
.error
.unwrap_or_else(|| "the server gave no reason".to_owned());
FlashData::error(format!(
"Settings saved, but the SMTP test failed: {reason}"
))
}
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let flag = |key: &str| form.bool_value(key);
let get = |key: &str| Some(form.clean(key));
@@ -896,7 +906,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
port: smtp_port,
username: clean("integration_smtp_username"),
password: clean("integration_smtp_password"),
secure: Some(form.bool_value("integration_smtp_secure")),
tls_mode: form
.first("integration_smtp_tls_mode")
.map(|value| Some(value.trim().to_owned()).filter(|mode| !mode.is_empty())),
}),
disable_new_ip_authorization: Some(
form.bool_value("integration_email_disable_new_ip_authorization"),
@@ -939,29 +951,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
}
}
fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTestRequest, String> {
let host = form
.clean("integration_smtp_host")
.ok_or_else(|| "SMTP host is required".to_owned())?;
let port = form
.first("integration_smtp_port")
.and_then(|value| value.trim().parse::<u16>().ok())
.ok_or_else(|| "SMTP port must be between 1 and 65535".to_owned())?;
let username = form
.clean("integration_smtp_username")
.ok_or_else(|| "SMTP username is required".to_owned())?;
let password = form
.clean("integration_smtp_password")
.ok_or_else(|| "SMTP password is required for validation".to_owned())?;
Ok(InstanceEmailSmtpTestRequest {
host,
port,
username,
password,
secure: form.bool_value("integration_smtp_secure"),
})
}
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
@@ -1369,6 +1358,49 @@ mod tests {
);
}
#[test]
fn build_integrations_update_sends_the_smtp_tls_mode_only_when_the_form_has_it() {
let smtp_update = |body: &[u8]| {
build_integrations_update(&MultiValueForm::parse(body))
.integrations
.and_then(|integrations| integrations.email)
.and_then(|email| email.smtp)
.expect("smtp update")
};
let body = |update| serde_json::to_value(update).expect("serialize smtp update");
let chosen = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=none");
assert_eq!(body(chosen), serde_json::json!({"tls_mode": "none"}));
let automatic = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=");
assert_eq!(body(automatic), serde_json::json!({"tls_mode": null}));
let from_an_older_page =
smtp_update(b"integration_email_present=1&integration_smtp_secure=true");
assert_eq!(body(from_an_older_page), serde_json::json!({}));
}
#[test]
fn smtp_test_flash_says_the_settings_were_saved_either_way() {
let passed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: true,
error: None,
});
assert_eq!(
passed.message,
"Settings saved and SMTP connection verified"
);
let failed = smtp_test_flash(InstanceEmailSmtpTestResponse {
ok: false,
error: Some("wrong version number".to_owned()),
});
assert_eq!(
failed.message,
"Settings saved, but the SMTP test failed: wrong version number"
);
}
#[test]
fn build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
@@ -546,6 +546,16 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
)
}
fn smtp_tls_mode_summary(mode: &str) -> &str {
match mode {
"implicit" => "implicit TLS",
"starttls" => "required STARTTLS",
"opportunistic" => "STARTTLS when the server offers it",
"none" => "no encryption",
other => other,
}
}
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
let description = match account_identity.mode {
AccountIdentityMode::Username => {
@@ -689,15 +699,34 @@ fn integrations_config_section(
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(select_input("integration_smtp_tls_mode", "SMTP encryption", &[
("", "Automatic"),
("implicit", "Implicit TLS"),
("starttls", "STARTTLS, required"),
("opportunistic", "STARTTLS, when the server offers it"),
("none", "None"),
], integrations.email.smtp.tls_mode.as_deref().unwrap_or("")))
p class="text-xs text-neutral-500" {
"Automatic follows FLUXER_EMAIL_SMTP_TLS_MODE when it is set, then the older FLUXER_EMAIL_SMTP_SECURE, where true means implicit TLS on any port. With neither set it uses implicit TLS on port 465 and requires STARTTLS on every other port."
}
@if let Some(mode) = integrations.email.smtp.effective_tls_mode.as_deref() {
p class="text-xs text-neutral-500" {
"Outgoing email currently uses " (smtp_tls_mode_summary(mode))
@if let Some(port) = integrations.email.smtp.port { " on port " (port) }
"."
}
}
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
span { "Save and test SMTP" }
}
}
p class="text-xs text-neutral-500" {
"The test saves this form first, then connects with the saved settings, the same ones outgoing email uses."
}
}
}