#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
use eframe::egui;
use smtp_test_tool::config::{default_save_path, discover_config_path, Config};
use smtp_test_tool::diagnostics::smtp_hints_for;
use smtp_test_tool::i18n::{self, t, t_with};
use smtp_test_tool::keystore::{default_keystore, Keystore};
use smtp_test_tool::locale as os_locale;
use smtp_test_tool::providers::{self, Provider};
use smtp_test_tool::runner::{TestOutcome, TestResults};
use smtp_test_tool::theme::{detect as detect_appearance, Appearance, ThemeChoice};
use smtp_test_tool::tls::Security;
use smtp_test_tool::{outlook_defaults, run_tests, Profile};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
#[derive(Clone, Copy, Debug)]
enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Clone, Debug)]
struct LogLine {
level: LogLevel,
text: String,
}
#[derive(Default)]
struct LogSink {
lines: Mutex<Vec<LogLine>>,
}
impl LogSink {
fn push(&self, level: LogLevel, text: String) {
if let Ok(mut g) = self.lines.lock() {
if g.len() > 5000 {
g.drain(..1000);
}
g.push(LogLine { level, text });
}
}
fn drain_into(&self, dst: &mut Vec<LogLine>) {
if let Ok(mut g) = self.lines.lock() {
dst.extend(g.drain(..));
}
}
}
struct GuiLayer {
sink: Arc<LogSink>,
}
impl<S> Layer<S> for GuiLayer
where
S: tracing::Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let mut visitor = FieldFmt::default();
event.record(&mut visitor);
let lvl = match *event.metadata().level() {
tracing::Level::TRACE => LogLevel::Trace,
tracing::Level::DEBUG => LogLevel::Debug,
tracing::Level::INFO => LogLevel::Info,
tracing::Level::WARN => LogLevel::Warn,
tracing::Level::ERROR => LogLevel::Error,
};
self.sink.push(lvl, visitor.message);
}
}
#[derive(Default)]
struct FieldFmt {
message: String,
}
impl tracing::field::Visit for FieldFmt {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{value:?}");
if self.message.starts_with('"') && self.message.ends_with('"') {
self.message = self.message[1..self.message.len() - 1].to_string();
}
} else {
self.message
.push_str(&format!(" {}={value:?}", field.name()));
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
} else {
self.message.push_str(&format!(" {}={value}", field.name()));
}
}
}
struct App {
cfg_path: Option<PathBuf>,
cfg: Config,
profile_name: String,
profile: Profile,
log_sink: Arc<LogSink>,
log_buf: Vec<LogLine>,
show_pwd: bool,
busy: bool,
keystore: Box<dyn Keystore>,
password_from_keychain: bool,
result_rx: Option<Receiver<TestResults>>,
last_results: TestResults,
to_csv: String,
cc_csv: String,
bcc_csv: String,
tab: Tab,
diagnose_input: String,
diagnose_hints: Vec<String>,
os_appearance: Appearance,
os_locale_code: Option<String>,
applied_appearance: Appearance,
}
#[derive(PartialEq, Copy, Clone)]
enum Tab {
Servers,
Send,
Tls,
Advanced,
Diagnose,
}
impl App {
fn new(sink: Arc<LogSink>, cc: &eframe::CreationContext<'_>) -> Self {
let cfg_path = discover_config_path();
let cfg = cfg_path
.as_ref()
.and_then(|p| match Config::load(p) {
Ok(c) => {
tracing::info!("loaded config {}", p.display());
Some(c)
}
Err(e) => {
tracing::warn!("failed to load config {}: {:#}", p.display(), e);
None
}
})
.unwrap_or_else(|| Config {
active: "default".into(),
profiles: [("default".into(), outlook_defaults())]
.into_iter()
.collect(),
});
let profile_name = cfg.active.clone();
let mut profile = cfg
.profile(&profile_name)
.cloned()
.unwrap_or_else(outlook_defaults);
let keystore = default_keystore();
let mut password_from_keychain = false;
if profile.password.is_none() {
if let Some(user) = profile.user.as_deref() {
match keystore.load(user) {
Ok(Some(pwd)) => {
tracing::info!("loaded password for {user} from OS keychain");
profile.password = Some(pwd);
password_from_keychain = true;
}
Ok(None) => {}
Err(e) => {
tracing::warn!("keychain load failed for {user}: {e:#}");
}
}
}
}
let os_locale_code = os_locale::detect();
let active_locale = match profile.locale.as_deref() {
Some(explicit) if i18n::is_supported(explicit) => explicit.to_string(),
_ => match &os_locale_code {
Some(code) if i18n::is_supported(code) => code.clone(),
_ => i18n::BASE.to_string(),
},
};
i18n::set_locale(&active_locale);
let os_appearance = detect_appearance();
let initial_choice = ThemeChoice::from_config_str(&profile.theme);
let initial = initial_choice.resolve(os_appearance);
cc.egui_ctx.set_visuals(visuals_for(initial));
if os_appearance == Appearance::Unknown && initial_choice == ThemeChoice::Auto {
tracing::info!("OS did not advertise a colour scheme; defaulting to dark");
}
let to_csv = profile.to.join(", ");
let cc_csv = profile.cc.join(", ");
let bcc_csv = profile.bcc.join(", ");
Self {
cfg_path,
cfg,
profile_name,
profile,
log_sink: sink,
log_buf: Vec::new(),
show_pwd: false,
busy: false,
result_rx: None,
last_results: TestResults::default(),
to_csv,
cc_csv,
bcc_csv,
tab: Tab::Servers,
diagnose_input: String::new(),
diagnose_hints: Vec::new(),
os_appearance,
os_locale_code,
applied_appearance: initial,
keystore,
password_from_keychain,
}
}
fn refresh_theme(&mut self, ctx: &egui::Context) {
let choice = ThemeChoice::from_config_str(&self.profile.theme);
let target = choice.resolve(self.os_appearance);
if target != self.applied_appearance {
ctx.set_visuals(visuals_for(target));
self.applied_appearance = target;
tracing::info!(
"theme: now {} (choice={}, os={:?})",
target_label(target),
choice.as_str(),
self.os_appearance
);
}
}
fn apply_provider(&mut self, p: &Provider) {
self.profile.smtp_host = p.smtp.host.into();
self.profile.smtp_port = p.smtp.port;
self.profile.smtp_security = p.smtp.security;
self.profile.imap_host = p.imap.host.into();
self.profile.imap_port = p.imap.port;
self.profile.imap_security = p.imap.security;
match p.pop {
Some(pop) => {
self.profile.pop_host = pop.host.into();
self.profile.pop_port = pop.port;
self.profile.pop_security = pop.security;
}
None => {
self.profile.pop_enabled = false;
}
}
tracing::info!("applied provider preset: {}", p.name);
}
fn run_tests_async(&mut self) {
self.profile.to = csv_to_vec(&self.to_csv);
self.profile.cc = csv_to_vec(&self.cc_csv);
self.profile.bcc = csv_to_vec(&self.bcc_csv);
let (tx, rx): (Sender<TestResults>, Receiver<TestResults>) = std::sync::mpsc::channel();
self.result_rx = Some(rx);
self.busy = true;
let profile = self.profile.clone();
thread::spawn(move || {
let r = run_tests(&profile);
let _ = tx.send(r);
});
}
fn save_config(&mut self) -> anyhow::Result<()> {
self.profile.to = csv_to_vec(&self.to_csv);
self.profile.cc = csv_to_vec(&self.cc_csv);
self.profile.bcc = csv_to_vec(&self.bcc_csv);
self.cfg
.upsert_profile(&self.profile_name, self.profile.clone());
self.cfg.active = self.profile_name.clone();
let target = self.cfg_path.clone().unwrap_or_else(default_save_path);
self.cfg.save(&target)?;
self.cfg_path = Some(target);
Ok(())
}
}
fn csv_to_vec(s: &str) -> Vec<String> {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
impl eframe::App for App {
fn ui(&mut self, root_ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = root_ui.ctx().clone();
self.refresh_theme(&ctx);
self.log_sink.drain_into(&mut self.log_buf);
if self.log_buf.len() > 5000 {
let drop = self.log_buf.len() - 5000;
self.log_buf.drain(..drop);
}
if let Some(rx) = &self.result_rx {
if let Ok(r) = rx.try_recv() {
self.last_results = r;
self.busy = false;
self.result_rx = None;
}
}
if self.busy {
ctx.request_repaint_after(std::time::Duration::from_millis(150));
}
egui::Panel::top("top").show_inside(root_ui, |ui| {
ui.horizontal(|ui| {
ui.label(t("ui.topbar.profile_label"));
let names = self.cfg.profile_names();
egui::ComboBox::from_id_salt("profile")
.selected_text(&self.profile_name)
.show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(&self.profile_name == n, n).clicked() {
self.profile_name = n.clone();
if let Some(p) = self.cfg.profile(n) {
self.profile = p.clone();
self.to_csv = self.profile.to.join(", ");
self.cc_csv = self.profile.cc.join(", ");
self.bcc_csv = self.profile.bcc.join(", ");
}
}
}
});
if ui
.button(t("ui.topbar.save_config"))
.on_hover_text(t("ui.topbar.save_config_tooltip"))
.clicked()
{
if let Err(e) = self.save_config() {
tracing::error!("save failed: {e:#}");
} else {
tracing::info!(
"Saved profile [{}] to {}",
self.profile_name,
self.cfg_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default()
);
}
}
let mut chosen: Option<&'static Provider> = None;
ui.menu_button(t("ui.topbar.provider_preset"), |ui| {
for p in providers::PROVIDERS {
let mut label = p.name.to_string();
if p.pop.is_none() {
label.push_str(" (no POP3)");
}
if ui.button(label).clicked() {
chosen = Some(p);
ui.close();
}
}
});
if let Some(p) = chosen {
self.apply_provider(p);
if let Some(note) = p.note {
tracing::info!("note: {note}");
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
self.cfg_path
.as_ref()
.map(|p| format!("{}", p.display()))
.unwrap_or_else(|| t("ui.topbar.config_path_none")),
);
});
});
});
egui::Panel::bottom("bottom").show_inside(root_ui, |ui| {
ui.horizontal(|ui| {
let run = ui.add_enabled(
!self.busy,
egui::Button::new(if self.busy {
t("ui.actions.running")
} else {
t("ui.actions.run_test")
}),
);
if run.clicked() {
self.run_tests_async();
}
ui.separator();
outcome_chip(ui, "SMTP", self.last_results.smtp);
outcome_chip(ui, "IMAP", self.last_results.imap);
outcome_chip(ui, "POP3", self.last_results.pop3);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(t("ui.actions.clear_log")).clicked() {
self.log_buf.clear();
}
});
});
});
egui::Panel::bottom("log")
.resizable(true)
.default_size(260.0)
.min_size(120.0)
.show_inside(root_ui, |ui| {
ui.label(egui::RichText::new(t("ui.log.heading")).strong());
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink([false, false])
.show(ui, |ui| {
for line in &self.log_buf {
let (color, tag) = level_style(line.level, ui.visuals().dark_mode);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(tag).color(color).monospace());
ui.label(egui::RichText::new(&line.text).monospace());
});
}
});
});
egui::CentralPanel::default().show_inside(root_ui, |ui| {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.tab, Tab::Servers, t("ui.tab.servers"));
ui.selectable_value(&mut self.tab, Tab::Send, t("ui.tab.send_mail"));
ui.selectable_value(&mut self.tab, Tab::Tls, t("ui.tab.tls_auth"));
ui.selectable_value(&mut self.tab, Tab::Advanced, t("ui.tab.advanced"));
ui.selectable_value(&mut self.tab, Tab::Diagnose, t("ui.tab.diagnose"));
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match self.tab {
Tab::Servers => tab_servers(ui, self),
Tab::Send => tab_send(ui, self),
Tab::Tls => tab_tls(ui, self),
Tab::Advanced => tab_advanced(ui, self),
Tab::Diagnose => tab_diagnose(ui, self),
});
});
}
}
fn visuals_for(a: Appearance) -> egui::Visuals {
match a {
Appearance::Light => egui::Visuals::light(),
Appearance::Dark | Appearance::Unknown => egui::Visuals::dark(),
}
}
fn target_label(a: Appearance) -> &'static str {
match a {
Appearance::Dark => "dark",
Appearance::Light => "light",
Appearance::Unknown => "dark (fallback)",
}
}
fn theme_label(choice: ThemeChoice, os: Appearance) -> String {
match choice {
ThemeChoice::Auto => t_with("ui.advanced.theme_follow_os", &[("hint", target_label(os))]),
ThemeChoice::Dark => t("ui.advanced.theme_dark"),
ThemeChoice::Light => t("ui.advanced.theme_light"),
}
}
fn outcome_chip(ui: &mut egui::Ui, name: &str, o: Option<TestOutcome>) {
let key_suffix = name.to_ascii_lowercase();
let (txt, col) = match o {
Some(TestOutcome::Pass) => (
t(&format!("ui.status.{key_suffix}_pass")),
egui::Color32::from_rgb(0x0e, 0x7c, 0x0e),
),
Some(TestOutcome::Fail) => (
t(&format!("ui.status.{key_suffix}_fail")),
egui::Color32::from_rgb(0xa3, 0x00, 0x00),
),
Some(TestOutcome::Skipped) => (
t(&format!("ui.status.{key_suffix}_skip")),
egui::Color32::GRAY,
),
None => (
t(&format!("ui.status.{key_suffix}_idle")),
egui::Color32::GRAY,
),
};
ui.label(egui::RichText::new(txt).color(col).monospace());
}
fn level_style(lvl: LogLevel, dark: bool) -> (egui::Color32, &'static str) {
if dark {
match lvl {
LogLevel::Trace => (egui::Color32::from_rgb(0xa0, 0xa0, 0xa0), "[TRACE]"),
LogLevel::Debug => (egui::Color32::from_rgb(0xa0, 0xa0, 0xa0), "[DEBUG]"),
LogLevel::Info => (egui::Color32::from_rgb(0xf0, 0xf0, 0xf0), "[INFO ]"),
LogLevel::Warn => (egui::Color32::from_rgb(0xff, 0xd1, 0x66), "[WARN ]"),
LogLevel::Error => (egui::Color32::from_rgb(0xff, 0x6b, 0x6b), "[ERROR]"),
}
} else {
match lvl {
LogLevel::Trace => (egui::Color32::from_rgb(0x55, 0x55, 0x55), "[TRACE]"),
LogLevel::Debug => (egui::Color32::from_rgb(0x55, 0x55, 0x55), "[DEBUG]"),
LogLevel::Info => (egui::Color32::from_rgb(0x11, 0x11, 0x11), "[INFO ]"),
LogLevel::Warn => (egui::Color32::from_rgb(0x8a, 0x4b, 0x00), "[WARN ]"),
LogLevel::Error => (egui::Color32::from_rgb(0xa3, 0x00, 0x00), "[ERROR]"),
}
}
}
fn tab_servers(ui: &mut egui::Ui, a: &mut App) {
const LABEL_W: f32 = 100.0;
const SHOW_W: f32 = 70.0; const HINT_W: f32 = 160.0;
ui.horizontal(|ui| {
ui.add_sized([LABEL_W, 0.0], egui::Label::new(t("ui.servers.username")));
let mut u = a.profile.user.clone().unwrap_or_default();
let resp = ui.add_sized(
[ui.available_width(), 0.0],
egui::TextEdit::singleline(&mut u),
);
if resp.changed() {
a.profile.user = Some(u).filter(|s| !s.is_empty());
}
});
ui.horizontal(|ui| {
ui.add_sized([LABEL_W, 0.0], egui::Label::new(t("ui.servers.password")));
let mut pwd = a.profile.password.clone().unwrap_or_default();
let entry_w = (ui.available_width() - SHOW_W).max(80.0);
let resp = ui.add_sized(
[entry_w, 0.0],
egui::TextEdit::singleline(&mut pwd).password(!a.show_pwd),
);
if resp.changed() {
a.profile.password = Some(pwd).filter(|s| !s.is_empty());
}
ui.checkbox(&mut a.show_pwd, t("ui.servers.show"));
});
ui.horizontal(|ui| {
ui.add_sized(
[LABEL_W, 0.0],
egui::Label::new(t("ui.servers.oauth_token")),
);
let mut token = a.profile.oauth_token.clone().unwrap_or_default();
let entry_w = (ui.available_width() - HINT_W).max(80.0);
let resp = ui.add_sized(
[entry_w, 0.0],
egui::TextEdit::singleline(&mut token).password(true),
);
if resp.changed() {
a.profile.oauth_token = Some(token).filter(|s| !s.is_empty());
}
ui.add_sized(
[HINT_W, 0.0],
egui::Label::new(egui::RichText::new(t("ui.servers.oauth_hint")).weak()),
);
});
ui.horizontal(|ui| {
ui.add_sized([LABEL_W, 0.0], egui::Label::new(""));
let user_set = a
.profile
.user
.as_deref()
.map(|s| !s.is_empty())
.unwrap_or(false);
let pwd_set = a
.profile
.password
.as_deref()
.map(|s| !s.is_empty())
.unwrap_or(false);
if ui
.add_enabled(
user_set && pwd_set,
egui::Button::new(t("ui.servers.save_to_keychain")),
)
.on_hover_text(t("ui.servers.save_to_keychain_tooltip"))
.clicked()
{
if let (Some(u), Some(p)) = (&a.profile.user, &a.profile.password) {
match a.keystore.save(u, p) {
Ok(()) => {
tracing::info!("saved password for {u} to OS keychain");
a.password_from_keychain = true;
}
Err(e) => tracing::error!("keychain save failed for {u}: {e:#}"),
}
}
}
if ui
.add_enabled(user_set, egui::Button::new(t("ui.servers.forget_keychain")))
.on_hover_text(t("ui.servers.forget_keychain_tooltip"))
.clicked()
{
if let Some(u) = &a.profile.user.clone() {
match a.keystore.forget(u) {
Ok(()) => {
tracing::info!("forgot keychain entry for {u}");
a.profile.password = None;
a.password_from_keychain = false;
}
Err(e) => tracing::error!("keychain forget failed for {u}: {e:#}"),
}
}
}
if a.password_from_keychain {
ui.label(egui::RichText::new(t("ui.servers.loaded_from_keychain")).weak());
}
});
ui.separator();
proto_block(
ui,
"SMTP",
&mut a.profile.smtp_enabled,
&mut a.profile.smtp_host,
&mut a.profile.smtp_port,
&mut a.profile.smtp_security,
);
proto_block(
ui,
"IMAP",
&mut a.profile.imap_enabled,
&mut a.profile.imap_host,
&mut a.profile.imap_port,
&mut a.profile.imap_security,
);
proto_block(
ui,
"POP3",
&mut a.profile.pop_enabled,
&mut a.profile.pop_host,
&mut a.profile.pop_port,
&mut a.profile.pop_security,
);
}
fn proto_block(
ui: &mut egui::Ui,
name: &str,
enabled: &mut bool,
host: &mut String,
port: &mut u16,
sec: &mut Security,
) {
ui.horizontal(|ui| {
let proto_key = name.to_ascii_lowercase();
ui.checkbox(enabled, t(&format!("ui.proto.test_{proto_key}")));
ui.label(t("ui.proto.host"));
ui.text_edit_singleline(host);
ui.label(t("ui.proto.port"));
ui.add(egui::DragValue::new(port).range(1..=65535));
ui.label(t("ui.proto.security"));
egui::ComboBox::from_id_salt(format!("{name}-sec"))
.selected_text(sec.as_str())
.show_ui(ui, |ui| {
ui.selectable_value(sec, Security::None, "none");
ui.selectable_value(sec, Security::StartTls, "starttls");
ui.selectable_value(sec, Security::Implicit, "ssl");
});
});
}
fn tab_send(ui: &mut egui::Ui, a: &mut App) {
ui.checkbox(&mut a.profile.send_test, t("ui.send.toggle"));
ui.separator();
egui::Grid::new("msg").num_columns(2).show(ui, |ui| {
opt_line(ui, &t("ui.send.mail_from"), &mut a.profile.mail_from);
opt_line(ui, &t("ui.send.from_header"), &mut a.profile.from_addr);
ui.label(t("ui.send.to"));
ui.text_edit_singleline(&mut a.to_csv);
ui.end_row();
ui.label(t("ui.send.cc"));
ui.text_edit_singleline(&mut a.cc_csv);
ui.end_row();
ui.label(t("ui.send.bcc"));
ui.text_edit_singleline(&mut a.bcc_csv);
ui.end_row();
opt_line(ui, &t("ui.send.reply_to"), &mut a.profile.reply_to);
ui.label(t("ui.send.subject"));
ui.text_edit_singleline(&mut a.profile.subject);
ui.end_row();
});
ui.label(t("ui.send.body"));
ui.add(
egui::TextEdit::multiline(&mut a.profile.body)
.desired_rows(6)
.desired_width(f32::INFINITY),
);
}
fn opt_line(ui: &mut egui::Ui, label: &str, v: &mut Option<String>) {
ui.label(label);
let mut buf = v.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut buf).changed() {
*v = if buf.is_empty() { None } else { Some(buf) };
}
ui.end_row();
}
fn tab_tls(ui: &mut egui::Ui, a: &mut App) {
ui.checkbox(&mut a.profile.insecure_tls, t("ui.tls.insecure"));
ui.horizontal(|ui| {
ui.label(t("ui.tls.ca_bundle"));
let mut buf = a
.profile
.ca_file
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
if ui.text_edit_singleline(&mut buf).changed() {
a.profile.ca_file = if buf.is_empty() {
None
} else {
Some(buf.into())
};
}
});
}
fn tab_advanced(ui: &mut egui::Ui, a: &mut App) {
egui::Grid::new("adv").num_columns(2).show(ui, |ui| {
ui.label(t("ui.advanced.timeout"));
ui.add(egui::DragValue::new(&mut a.profile.timeout_secs).range(1..=600));
ui.end_row();
ui.label(t("ui.advanced.ehlo"));
let mut e = a.profile.ehlo_name.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut e).changed() {
a.profile.ehlo_name = if e.is_empty() { None } else { Some(e) };
}
ui.end_row();
ui.label(t("ui.advanced.imap_folder"));
ui.text_edit_singleline(&mut a.profile.imap_folder);
ui.end_row();
ui.label(t("ui.advanced.log_level"));
egui::ComboBox::from_id_salt("loglvl")
.selected_text(&a.profile.log_level)
.show_ui(ui, |ui| {
for lv in ["trace", "debug", "info", "warn", "error"] {
ui.selectable_value(&mut a.profile.log_level, lv.into(), lv);
}
});
ui.end_row();
ui.label(t("ui.advanced.theme"));
let mut current = ThemeChoice::from_config_str(&a.profile.theme);
let previous = current;
egui::ComboBox::from_id_salt("themechoice")
.selected_text(theme_label(current, a.os_appearance))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut current,
ThemeChoice::Auto,
theme_label(ThemeChoice::Auto, a.os_appearance),
);
ui.selectable_value(&mut current, ThemeChoice::Dark, t("ui.advanced.theme_dark"));
ui.selectable_value(
&mut current,
ThemeChoice::Light,
t("ui.advanced.theme_light"),
);
});
if current != previous {
a.profile.theme = current.as_str().to_string();
}
ui.end_row();
ui.label(t("ui.advanced.language"));
let current_code = i18n::current_locale();
let os_code = a.os_locale_code.as_deref();
let os_supported = os_code
.map(|c| i18n::is_supported(c) && c != i18n::BASE)
.unwrap_or(false);
let display_label = |code: &str| -> String {
format!("{} ({code})", i18n::native_name(code))
};
egui::ComboBox::from_id_salt("langchoice")
.selected_text(display_label(¤t_code))
.show_ui(ui, |ui| {
if os_supported {
if let Some(c) = os_code {
let selected = current_code == c;
if ui.selectable_label(selected, display_label(c)).clicked() && !selected {
i18n::set_locale(c);
a.profile.locale = Some(c.to_string());
}
}
}
let en_selected = current_code == i18n::BASE;
if ui
.selectable_label(en_selected, display_label(i18n::BASE))
.clicked()
&& !en_selected
{
i18n::set_locale(i18n::BASE);
a.profile.locale = Some(i18n::BASE.to_string());
}
});
if !os_supported {
if let Some(code) = os_code {
ui.label(
egui::RichText::new(t_with(
"ui.advanced.language_unsupported",
&[("code", code)],
))
.weak(),
);
}
}
ui.end_row();
});
}
fn tab_diagnose(ui: &mut egui::Ui, a: &mut App) {
ui.label(t("ui.diagnose.intro"));
ui.add_space(6.0);
let avail_h = ui.available_height();
let input_h = (avail_h * 0.4).clamp(120.0, 260.0);
ui.add_sized(
[ui.available_width(), input_h],
egui::TextEdit::multiline(&mut a.diagnose_input)
.hint_text(t("ui.diagnose.input_placeholder"))
.desired_rows(8),
);
ui.horizontal(|ui| {
let has_input = !a.diagnose_input.trim().is_empty();
if ui
.add_enabled(has_input, egui::Button::new(t("ui.diagnose.analyse")))
.clicked()
{
a.diagnose_hints = smtp_hints_for(&a.diagnose_input);
if a.diagnose_hints.is_empty() {
tracing::info!("diagnose: no known patterns matched");
} else {
tracing::info!(
"diagnose: {} hint line(s) generated",
a.diagnose_hints.len()
);
}
}
if ui.button(t("ui.diagnose.clear")).clicked() {
a.diagnose_input.clear();
a.diagnose_hints.clear();
}
});
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new(t("ui.diagnose.hints_heading")).strong());
if a.diagnose_hints.is_empty() {
ui.label(egui::RichText::new(t("ui.diagnose.no_hints_yet")).weak());
} else {
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for line in &a.diagnose_hints {
ui.label(egui::RichText::new(line).monospace());
}
});
}
}
fn main() -> eframe::Result<()> {
let sink = Arc::new(LogSink::default());
let layer = GuiLayer { sink: sink.clone() };
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("info,eframe=warn,winit=warn,wgpu_core=warn,naga=warn")
});
tracing_subscriber::registry()
.with(filter)
.with(layer)
.init();
let _keep = LevelFilter::INFO;
let opts = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([960.0, 760.0])
.with_min_inner_size([720.0, 520.0])
.with_title("SMTP Test Tool"),
..Default::default()
};
eframe::run_native(
"SMTP Test Tool",
opts,
Box::new(|cc| Ok(Box::new(App::new(sink, cc)))),
)
}